import { CalendarPlus, FilePlus2, ListPlus, Pencil, Plus, Search, ShieldCheck, Vote } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ActionBlockerHint, Button, DocumentationHelpLink, DismissibleAlert, FilterBar, IconButton, LoadingIndicator, PageScrollViewport, StatePanel, StatusBadge, hasScope, i18nMessage, usePlatformLanguage, WorkspaceActionBar, WorkspaceFrame, type PlatformRouteContext } from "@govoplan/core-webui"; import { listCommitteeRecords, type CommitteeObjectKind, type CommitteeRecord } from "../../api/committee"; import CommitteeBallotDialog from "./CommitteeBallotDialog"; import CommitteeRecordDialog from "./CommitteeRecordDialog"; import { COMMITTEE_DOCUMENTATION, COMMITTEE_INTERFACE_I18N, committeeDisabledReason } from "./interfacePatterns"; import { canReviseCommitteeRecord } from "./lifecycle"; type EditorTarget = { kind: CommitteeObjectKind; parentId?: string | null; record?: CommitteeRecord | null; }; export default function CommitteePage({ settings, auth }: PlatformRouteContext) { const { language, translateText } = usePlatformLanguage(); const tenantId = auth.active_tenant?.id ?? auth.tenant.id; const canWrite = hasScope(auth, "committee:workspace:write"); const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize"); const [query, setQuery] = useState(""); const [bodies, setBodies] = useState([]); const [meetings, setMeetings] = useState([]); const [agendaItems, setAgendaItems] = useState([]); const [votes, setVotes] = useState([]); const [minutes, setMinutes] = useState([]); const [bodyId, setBodyId] = useState(""); const [meetingId, setMeetingId] = useState(""); const [agendaId, setAgendaId] = useState(""); const [editor, setEditor] = useState(null); const [ballotRecord, setBallotRecord] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const selectedBody = bodies.find((item) => item.object_id === bodyId) ?? null; const selectedMeeting = meetings.find((item) => item.object_id === meetingId) ?? null; const selectedAgenda = agendaItems.find((item) => item.object_id === agendaId) ?? null; const loadBodies = useCallback(async (signal?: AbortSignal) => { const response = await listCommitteeRecords( settings, "body", { query: query.trim(), limit: 200 }, signal ); setBodies(response.records); setBodyId((current) => response.records.some((item) => item.object_id === current) ? current : response.records[0]?.object_id ?? ""); }, [query, settings]); const refresh = useCallback(async () => { setLoading(true); setError(""); try { await loadBodies(); } catch (reason) { setError(reason instanceof Error ? reason.message : "Committee workspace could not be loaded."); } finally { setLoading(false); } }, [loadBodies]); useEffect(() => { const controller = new AbortController(); setLoading(true); loadBodies(controller.signal). catch((reason) => { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Committee bodies could not be loaded."); } }). finally(() => setLoading(false)); return () => controller.abort(); }, [loadBodies]); useEffect(() => { if (!bodyId) { setMeetings([]); setMeetingId(""); return; } const controller = new AbortController(); listCommitteeRecords(settings, "meeting", { parentId: bodyId }, controller.signal). then((response) => { setMeetings(response.records); setMeetingId((current) => response.records.some((item) => item.object_id === current) ? current : response.records[0]?.object_id ?? ""); }). catch((reason) => { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Committee meetings could not be loaded."); } }); return () => controller.abort(); }, [bodyId, settings]); useEffect(() => { if (!meetingId) { setAgendaItems([]); setMinutes([]); setAgendaId(""); return; } const controller = new AbortController(); Promise.all([ listCommitteeRecords(settings, "agenda_item", { parentId: meetingId }, controller.signal), listCommitteeRecords(settings, "minute", { parentId: meetingId }, controller.signal) ]). then(([agenda, nextMinutes]) => { const ordered = [...agenda.records].sort( (left, right) => Number(left.attributes.position ?? 0) - Number(right.attributes.position ?? 0) ); setAgendaItems(ordered); setMinutes(nextMinutes.records); setAgendaId((current) => ordered.some((item) => item.object_id === current) ? current : ordered[0]?.object_id ?? ""); }). catch((reason) => { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Meeting details could not be loaded."); } }); return () => controller.abort(); }, [meetingId, settings]); useEffect(() => { if (!agendaId) { setVotes([]); return; } const controller = new AbortController(); listCommitteeRecords(settings, "vote", { parentId: agendaId }, controller.signal). then((response) => setVotes(response.records)). catch((reason) => { if ((reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "Votes could not be loaded."); } }); return () => controller.abort(); }, [agendaId, settings]); const meetingTime = useMemo( () => selectedMeeting ? `${formatDateTime(selectedMeeting.attributes.starts_at, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}` : "", [language, selectedMeeting] ); return (
} scope="workspace" variant="collection" refreshable reloadAction={{ onReload: () => void refresh(), loading }} className="committee-toolbar" contextActions={ { event.preventDefault(); void refresh(); }} className="committee-search"> } createAction={} /> {error ? {error} : null} {loading && bodies.length === 0 ? : null} {!canWrite ? ( ) : null}
{selectedBody ? (
} disabled={!canWrite || !canReviseCommitteeRecord(selectedBody)} disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedBody) })} onClick={() => setEditor({ kind: "body", record: selectedBody })} />
) : null}
meetingSecondary(record, language, translateText)} />
{!selectedMeeting ? ( ) : ( <>
{meetingTime}

{selectedMeeting.title}

} disabled={!canWrite || !canReviseCommitteeRecord(selectedMeeting)} disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedMeeting) })} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} />
setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })} > {selectedAgenda ? ( setEditor({ kind: "vote", parentId: selectedAgenda.object_id })} > ) : null} setEditor({ kind: "minute", parentId: selectedMeeting.object_id })} > )}
{editor ? ( setEditor(null)} onSaved={() => void refresh()} /> ) : null} {ballotRecord ? ( setBallotRecord(null)} onSaved={() => void refresh()} /> ) : null}
); } function PanelHeading({ title, count }: { title: string; count: number }) { return

{title}

{count}
; } function RecordList({ records, selectedId, onSelect, secondary }: { records: CommitteeRecord[]; selectedId: string; onSelect: (id: string) => void; secondary?: (record: CommitteeRecord) => string; }) { if (records.length === 0) return ; return
{records.map((record) => ( ))}
; } function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { return

{title}

{action}
{children}
; } function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryAction }: { records: CommitteeRecord[]; editDisabledReason: (record: CommitteeRecord) => string | undefined; onEdit: (record: CommitteeRecord) => void; detail: (record: CommitteeRecord) => string; secondaryAction?: (record: CommitteeRecord) => ReactNode; }) { if (records.length === 0) return ; return
{records.map((record) => (
{record.title}{detail(record)} {secondaryAction?.(record)} } disabled={Boolean(editDisabledReason(record))} disabledReason={editDisabledReason(record)} onClick={() => onEdit(record)} />
))}
; } function meetingSecondary(record: CommitteeRecord, locale: string, translateText: (value: string) => string): string { return `${formatDateTime(record.attributes.starts_at, locale)} - ${translateText(stateLabel(record.state))}`; } function voteSummary(record: CommitteeRecord, translateText: (value: string) => string): string { const cast = Number(record.attributes.cast_count ?? 0); const eligible = Number(record.attributes.eligible_count ?? 0); return i18nMessage("i18n:govoplan-committee.vote_summary", { method: translateText(domainLabel(String(record.attributes.method ?? "recorded"))), cast, eligible }); } function isProviderBallotReady(record: CommitteeRecord): boolean { return record.state === "open" && Boolean( String(record.attributes.voting_ballot_id ?? "").trim() || String(record.attributes.provider_id ?? "").trim() ); } function statusTone(state: string): "active" | "inactive" | "warning" { if (["active", "open", "accepted", "decided", "closed"].includes(state)) return "active"; if (["cancelled", "retired", "withdrawn"].includes(state)) return "inactive"; return "warning"; } function formatDateTime(value: unknown, locale?: string): string { if (!value) return "Date not set"; return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value))); } function formatTime(value: unknown, locale?: string): string { if (!value) return "-"; return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(new Date(String(value))); } function stateLabel(value: string): string { return `i18n:govoplan-committee.state_${value}`; } function domainLabel(value: string): string { return `i18n:govoplan-committee.domain_${value}`; }