221 lines
7.2 KiB
TypeScript
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;
|
|
}
|