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; 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(initialDraft); const [baseline, setBaseline] = useState(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 { 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 ( } >
{error ? {error} : null} setDraft({ ...draft, title: event.target.value })} /> {kind === "body" ? ( setDraft({ ...draft, organizationUnitId: event.target.value })} /> ) : null} {kind === "meeting" ? ( setDraft({ ...draft, startsAt: event.target.value })} /> setDraft({ ...draft, endsAt: event.target.value })} /> ) : null} {kind === "agenda_item" ? ( <> setDraft({ ...draft, position: event.target.value })} /> setDraft({ ...draft, subjectId: event.target.value })} /> {draft.state === "decided" ? ( setDraft({ ...draft, decisionId: event.target.value })} /> ) : null} ) : null} {kind === "vote" ? ( <> setDraft({ ...draft, eligibleCount: event.target.value })} /> setDraft({ ...draft, providerId: event.target.value })} /> setDraft({ ...draft, votingBallotId: event.target.value })} /> setDraft({ ...draft, choices: event.target.value })} /> {draft.state === "closed" ? (
setDraft({ ...draft, castCount: event.target.value })} />
{choices.map((choice) => ( setDraft({ ...draft, counts: { ...draft.counts, [choice]: event.target.value } })} /> ))}
) : null} ) : null} {kind === "minute" ? ( <> setDraft({ ...draft, contentRecordId: event.target.value })} /> {draft.state === "accepted" || draft.state === "corrected" ? ( ) : null} ) : null} setDraft({ ...draft, changeReason: event.target.value })} />
{ setConfirmLifecycleChange(false); void save(); }} onCancel={() => setConfirmLifecycleChange(false)} />
); } function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) { return ( setDraft({ ...draft, approvalId: event.target.value })} /> setDraft({ ...draft, evidenceId: event.target.value })} /> ); } 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 { 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 { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function firstMapping(value: unknown): Record { 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()); }