Files
govoplan-committee/webui/src/features/committee/CommitteeRecordDialog.tsx
T

520 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { FormGrid,
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
saveCommitteeRecord,
type CommitteeObjectKind,
type CommitteeRecord
} from "../../api/committee";
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
import {
COMMITTEE_FIELD_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N,
committeeDisabledReason
} from "./interfacePatterns";
type Draft = {
title: string;
state: string;
changeReason: string;
organizationUnitId: string;
startsAt: string;
endsAt: string;
position: string;
subjectKind: string;
subjectId: string;
choices: string;
method: string;
eligibleCount: string;
castCount: string;
counts: Record<string, string>;
quorumMet: boolean;
approvalId: string;
evidenceId: string;
providerId: string;
votingBallotId: string;
contentRecordId: string;
decisionId: string;
};
export default function CommitteeRecordDialog({
settings,
tenantId,
kind,
parentId,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
open: boolean;
onClose: () => void;
onSaved: (record: CommitteeRecord) => void;
}) {
const { translateText } = usePlatformLanguage();
const initialDraft = useMemo(() => draftFromRecord(kind, record), [kind, record]);
const [draft, setDraft] = useState<Draft>(initialDraft);
const [baseline, setBaseline] = useState<Draft>(initialDraft);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmLifecycleChange, setConfirmLifecycleChange] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const choices = useMemo(
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
[draft.choices]
);
const stateOptions = useMemo(
() => committeeStateOptions(kind, record),
[kind, record]
);
useEffect(() => {
if (!open) return;
setDraft(initialDraft);
setBaseline(initialDraft);
setError("");
setConfirmLifecycleChange(false);
}, [initialDraft, open]);
const dirty = draftKey(draft) !== draftKey(baseline);
const incomplete = !draft.title.trim() || !draft.changeReason.trim();
async function save(closeAfter = true): Promise<boolean> {
setBusy(true);
setError("");
try {
const payload = recordFromDraft({
tenantId,
kind,
parentId,
record,
draft,
choices
});
const saved = await saveCommitteeRecord(
settings,
payload,
record?.revision
);
const nextDraft = draftFromRecord(kind, saved);
setDraft(nextDraft);
setBaseline(nextDraft);
onSaved(saved);
if (closeAfter) onClose();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
onSave: () => save(false),
onDiscard: () => setDraft(baseline),
title: "i18n:govoplan-committee.unsaved_title",
message: "i18n:govoplan-committee.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function requestSave() {
if (record && draft.state !== record.state) {
setConfirmLifecycleChange(true);
return;
}
void save();
}
return (
<Dialog
open={open}
title={i18nMessage("i18n:govoplan-committee.record_dialog_title", {
action: translateText(record ? "Edit" : "New"),
kind: translateText(kindLabel(kind))
})}
onClose={requestClose}
closeDisabled={busy}
portal
className="committee-record-dialog"
footer={
<>
<Button disabled={busy} disabledReason={committeeDisabledReason({ busy })} onClick={requestClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || incomplete}
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
onClick={requestSave}
>
{busy ? "Saving" : "Save"}
</Button>
</>
}
>
<div className="committee-record-form">
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.title}
maxLength={500}
disabled={busy}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</FormField>
<FormField label="State" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<select
value={draft.state}
disabled={busy}
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
>
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
</select>
</FormField>
</FormGrid>
{kind === "body" ? (
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.organizationUnitId}
disabled={busy}
onChange={(event) => setDraft({ ...draft, organizationUnitId: event.target.value })}
/>
</FormField>
) : null}
{kind === "meeting" ? (
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Starts">
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
</FormField>
<FormField label="Ends">
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
</FormField>
</FormGrid>
) : null}
{kind === "agenda_item" ? (
<>
<FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Position">
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
</FormField>
<FormField label="Subject type">
<select value={draft.subjectKind} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectKind: event.target.value })}>
<option value="case">Case</option>
<option value="service">Service</option>
<option value="work_item">Work item</option>
<option value="record">Record</option>
</select>
</FormField>
<FormField label="Subject ID">
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
</FormField>
</FormGrid>
{draft.state === "decided" ? (
<FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
</FormField>
) : null}
</>
) : null}
{kind === "vote" ? (
<>
<FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
<option value="recorded">Recorded</option>
<option value="public">Public</option>
<option value="secret">Secret</option>
</select>
</FormField>
<FormField label="Eligible voters">
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
</FormField>
<FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField>
</FormGrid>
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
</FormField>
<FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField>
{draft.state === "closed" ? (
<div className="committee-vote-result-fields">
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Votes cast">
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
</FormField>
<FormField label="Quorum">
<select value={draft.quorumMet ? "met" : "not-met"} disabled={busy} onChange={(event) => setDraft({ ...draft, quorumMet: event.target.value === "met" })}>
<option value="met">Met</option>
<option value="not-met">Not met</option>
</select>
</FormField>
</FormGrid>
<div className="committee-count-grid">
{choices.map((choice) => (
<FormField key={choice} label={`${choice} votes`}>
<input
type="number"
min="0"
value={draft.counts[choice] ?? "0"}
disabled={busy}
onChange={(event) => setDraft({
...draft,
counts: { ...draft.counts, [choice]: event.target.value }
})}
/>
</FormField>
))}
</div>
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
</div>
) : null}
</>
) : null}
{kind === "minute" ? (
<>
<FormField label="Minutes record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
</FormField>
{draft.state === "accepted" || draft.state === "corrected" ? (
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
) : null}
</>
) : null}
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.changeReason}
maxLength={1000}
disabled={busy}
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
/>
</FormField>
</div>
<ConfirmDialog
open={confirmLifecycleChange}
title="i18n:govoplan-committee.save_state_title"
message={record ? i18nMessage("i18n:govoplan-committee.confirm_state_change", {
kind: translateText(kindLabel(kind)),
from: translateText(stateLabel(record.state)),
to: translateText(stateLabel(draft.state))
}) : ""}
confirmLabel="Save"
tone={["cancelled", "retired", "withdrawn"].includes(draft.state) ? "danger" : "default"}
busy={busy}
onConfirm={() => {
setConfirmLifecycleChange(false);
void save();
}}
onCancel={() => setConfirmLifecycleChange(false)}
/>
</Dialog>
);
}
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return (
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
</FormField>
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
</FormField>
</FormGrid>
);
}
function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | null): Draft {
const attributes = record?.attributes ?? {};
const startsAt = localDateTime(attributes.starts_at, 60);
const endsAt = localDateTime(attributes.ends_at, 120);
const subject = firstMapping(attributes.subject_refs);
const approval = mapping(attributes.approval_ref);
const content = mapping(attributes.content_ref);
const decision = mapping(attributes.decision_ref);
const organization = mapping(attributes.organization_unit_ref);
const counts = mapping(attributes.counts);
return {
title: record?.title ?? "",
state: record?.state ?? COMMITTEE_STATES[kind][0],
changeReason: record ? "" : `Created ${labelForKind(kind)}.`,
organizationUnitId: text(organization.object_id),
startsAt,
endsAt,
position: String(attributes.position ?? 1),
subjectKind: text(subject.kind) || "case",
subjectId: text(subject.object_id),
choices: array(attributes.choices).join(", ") || "yes, no, abstain",
method: text(attributes.method) || "recorded",
eligibleCount: String(attributes.eligible_count ?? 0),
castCount: String(attributes.cast_count ?? 0),
counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, String(value)])),
quorumMet: attributes.quorum_met !== false,
approvalId: text(approval.object_id),
evidenceId: text(firstMapping(record?.evidence).evidence_id),
providerId: text(attributes.provider_id),
votingBallotId: text(attributes.voting_ballot_id),
contentRecordId: text(content.object_id),
decisionId: text(decision.object_id)
};
}
function recordFromDraft({ tenantId, kind, parentId, record, draft, choices }: {
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
draft: Draft;
choices: string[];
}): CommitteeRecord {
const attributes = attributesFromDraft(tenantId, kind, draft, choices);
const evidence = needsEvidence(kind, draft.state) && draft.evidenceId.trim()
? [{
kind: "record",
owner_module: "records",
evidence_id: draft.evidenceId.trim(),
tenant_id: tenantId,
version: "1",
captured_at: new Date().toISOString()
}]
: record?.evidence ?? [];
return {
tenant_id: tenantId,
object_kind: kind,
object_id: record?.object_id ?? crypto.randomUUID(),
revision: (record?.revision ?? 0) + 1,
state: draft.state,
title: draft.title.trim(),
parent_id: record?.parent_id ?? parentId ?? null,
recorded_at: new Date().toISOString(),
change_reason: draft.changeReason.trim(),
attributes,
context: record?.context ?? null,
evidence,
record_refs: record?.record_refs ?? []
};
}
function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft: Draft, choices: string[]): Record<string, unknown> {
if (kind === "body") return {
organization_unit_ref: reference("organization_unit", "organizations", draft.organizationUnitId, tenantId),
function_refs: []
};
if (kind === "meeting") return {
starts_at: new Date(draft.startsAt).toISOString(),
ends_at: new Date(draft.endsAt).toISOString()
};
if (kind === "agenda_item") return {
position: Number(draft.position),
subject_refs: [reference(draft.subjectKind, ownerForKind(draft.subjectKind), draft.subjectId, tenantId)],
...(draft.state === "decided" ? {
decision_ref: reference("decision", "decisions", draft.decisionId, tenantId)
} : {})
};
if (kind === "vote") return {
method: draft.method,
choices,
eligible_count: Number(draft.eligibleCount),
cast_count: Number(draft.castCount),
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
...(draft.state === "closed" ? {
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
quorum_met: draft.quorumMet,
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
return {
content_ref: reference("record", "records", draft.contentRecordId, tenantId),
...(draft.state === "accepted" || draft.state === "corrected" ? {
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
}
function reference(kind: string, owner: string, objectId: string, tenantId: string) {
return { kind, owner_module: owner, object_id: objectId.trim(), tenant_id: tenantId, version: "1" };
}
function ownerForKind(kind: string): string {
return { case: "cases", service: "services", work_item: "workflow_engine", record: "records" }[kind] ?? kind;
}
function needsEvidence(kind: CommitteeObjectKind, state: string): boolean {
return (kind === "vote" && state === "closed")
|| (kind === "minute" && ["accepted", "corrected"].includes(state));
}
function localDateTime(value: unknown, offsetMinutes: number): string {
const date = value ? new Date(String(value)) : new Date(Date.now() + offsetMinutes * 60_000);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function mapping(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function firstMapping(value: unknown): Record<string, unknown> {
return mapping(Array.isArray(value) ? value[0] : undefined);
}
function array(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function labelForKind(kind: CommitteeObjectKind): string {
return humanize(kind);
}
function kindLabel(kind: CommitteeObjectKind): string {
return `i18n:govoplan-committee.kind_${kind}`;
}
function stateLabel(state: string): string {
return `i18n:govoplan-committee.state_${state}`;
}
function draftKey(draft: Draft): string {
return JSON.stringify(draft);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}