Files
govoplan-core/webui/src/layout/TemporalDataMenu.tsx
T

221 lines
7.2 KiB
TypeScript

import { Calendar, CalendarOff } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import Button from "../components/Button";
import DismissibleAlert from "../components/DismissibleAlert";
import FormField from "../components/FormField";
import SegmentedControl from "../components/SegmentedControl";
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useTemporalDataContext } from "../platform/TemporalContext";
import {
normalizeTemporalDataSelection,
type TemporalDataSelection,
type TemporalValidityMode
} from "../platform/temporal";
export default function TemporalDataMenu() {
const { selection, applySelection, isDefault } = useTemporalDataContext();
const { translateText } = usePlatformLanguage();
const { requestNavigation } = useUnsavedChanges();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
const [error, setError] = useState("");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!open) setDraft(selection);
}, [open, selection]);
function toggleOpen() {
if (!open) {
setDraft(selection);
setError("");
}
setOpen(!open);
}
function selectValidityMode(validityMode: TemporalValidityMode) {
setDraft((current) => ({
...current,
validityMode,
validAt: validityMode === "at"
? current.validAt ?? new Date().toISOString()
: null
}));
}
function selectRecordedMode(mode: "latest" | "at") {
setDraft((current) => ({
...current,
recordedAt: mode === "at"
? current.recordedAt ?? new Date().toISOString()
: null
}));
}
function apply() {
try {
const normalized = normalizeTemporalDataSelection(draft);
requestNavigation(() => {
applySelection(normalized);
setOpen(false);
setError("");
});
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "i18n:govoplan-core.temporal_selection_invalid"
);
}
}
const iconTitle = selection.validityMode === "all"
? "i18n:govoplan-core.all_validity_periods"
: selection.validityMode === "at"
? "i18n:govoplan-core.historical_data_state"
: selection.recordedAt
? "i18n:govoplan-core.historical_recorded_state"
: "i18n:govoplan-core.current_data";
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
return (
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
<button
type="button"
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
data-help-context-id="core.temporal-data-context"
data-help-module-id="core"
data-help-scope="action"
onClick={toggleOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={translateText(iconTitle)}
title={translateText(iconTitle)}
>
<Icon size={18} aria-hidden="true" />
</button>
{open && (
<div
className="dropdown-menu temporal-data-menu"
role="dialog"
aria-label={translateText("i18n:govoplan-core.data_state")}
>
<div className="temporal-data-menu-heading">
<strong>i18n:govoplan-core.data_state</strong>
</div>
<SegmentedControl<TemporalValidityMode>
className="temporal-validity-control"
value={draft.validityMode}
width="fill"
size="equal"
ariaLabel={translateText("i18n:govoplan-core.validity")}
onChange={selectValidityMode}
options={[
{ id: "current", label: "i18n:govoplan-core.current" },
{ id: "at", label: "i18n:govoplan-core.at_time" },
{ id: "all", label: "i18n:govoplan-core.all" }
]}
/>
{draft.validityMode === "at" && (
<FormField
label="i18n:govoplan-core.valid_at"
help="i18n:govoplan-core.valid_at_help"
>
<input
type="datetime-local"
value={toLocalInput(draft.validAt)}
onChange={(event) => setDraft((current) => ({
...current,
validAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
<div className="temporal-recorded-section">
<FormField
label="i18n:govoplan-core.recorded_state"
help="i18n:govoplan-core.recorded_state_help"
>
<select
value={draft.recordedAt ? "at" : "latest"}
onChange={(event) => selectRecordedMode(
event.target.value === "at" ? "at" : "latest"
)}
>
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
<option value="at">i18n:govoplan-core.recorded_by_time</option>
</select>
</FormField>
{draft.recordedAt && (
<FormField label="i18n:govoplan-core.recorded_by">
<input
type="datetime-local"
value={toLocalInput(draft.recordedAt)}
onChange={(event) => setDraft((current) => ({
...current,
recordedAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
</div>
<p className="temporal-data-explanation">
i18n:govoplan-core.temporal_data_explanation
</p>
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
<div className="temporal-data-menu-actions">
<Button
type="button"
variant="primary"
onClick={apply}
disabled={validDateMissing}
disabledReason={validDateMissing
? "i18n:govoplan-core.select_valid_date_time"
: undefined}
>
i18n:govoplan-core.apply_data_state
</Button>
</div>
</div>
)}
</div>
);
}
function toLocalInput(value: string | null): string {
if (!value) return "";
const instant = new Date(value);
if (!Number.isFinite(instant.getTime())) return "";
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fromLocalInput(value: string): string | null {
if (!value) return null;
const instant = new Date(value);
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
}