feat: implement committee decision workspace

This commit is contained in:
2026-08-01 17:48:25 +02:00
parent 232329ac52
commit 758b59ca03
28 changed files with 4909 additions and 32 deletions
@@ -0,0 +1,401 @@
import {
CalendarPlus,
FilePlus2,
ListPlus,
Pencil,
Plus,
RefreshCw,
Search,
ShieldCheck,
Vote
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listCommitteeRecords,
type CommitteeObjectKind,
type CommitteeRecord
} from "../../api/committee";
import CommitteeBallotDialog from "./CommitteeBallotDialog";
import CommitteeRecordDialog from "./CommitteeRecordDialog";
import { canReviseCommitteeRecord } from "./lifecycle";
type EditorTarget = {
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
};
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
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<CommitteeRecord[]>([]);
const [meetings, setMeetings] = useState<CommitteeRecord[]>([]);
const [agendaItems, setAgendaItems] = useState<CommitteeRecord[]>([]);
const [votes, setVotes] = useState<CommitteeRecord[]>([]);
const [minutes, setMinutes] = useState<CommitteeRecord[]>([]);
const [bodyId, setBodyId] = useState("");
const [meetingId, setMeetingId] = useState("");
const [agendaId, setAgendaId] = useState("");
const [editor, setEditor] = useState<EditorTarget | null>(null);
const [ballotRecord, setBallotRecord] = useState<CommitteeRecord | null>(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)} - ${formatTime(selectedMeeting.attributes.ends_at)}`
: "",
[selectedMeeting]
);
return (
<main className="committee-page">
<div className="committee-shell">
<div className="committee-toolbar">
<form onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
<Search size={16} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
</form>
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
{canWrite ? (
<Button variant="primary" onClick={() => setEditor({ kind: "body" })}>
<Plus size={16} aria-hidden="true" />
New body
</Button>
) : null}
</div>
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
<div className="committee-workspace">
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
<PanelHeading title="Bodies" count={bodies.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
</PageScrollViewport>
{selectedBody && canWrite ? (
<div className="committee-panel-actions">
{canReviseCommitteeRecord(selectedBody) ? <IconButton label="Edit body" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null}
<Button variant="primary" onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}>
<CalendarPlus size={16} aria-hidden="true" />
New meeting
</Button>
</div>
) : null}
</section>
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
<PanelHeading title="Meetings" count={meetings.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={meetingSecondary} />
</PageScrollViewport>
</section>
<section className="committee-detail" aria-label="Meeting workspace">
{!selectedMeeting ? (
<div className="committee-empty">Select or create a meeting.</div>
) : (
<>
<div className="committee-detail-heading">
<div>
<span>{meetingTime}</span>
<h1>{selectedMeeting.title}</h1>
</div>
<StatusBadge status={statusTone(selectedMeeting.state)} label={humanize(selectedMeeting.state)} />
{canWrite && canReviseCommitteeRecord(selectedMeeting) ? <IconButton label="Edit meeting" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null}
</div>
<PageScrollViewport className="committee-detail-scroll">
<WorkspaceSection
title="Agenda"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}>
<ListPlus size={16} aria-hidden="true" />
Add item
</Button>
) : null}
>
<div className="committee-agenda-list">
{agendaItems.map((item) => (
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
<button type="button" onClick={() => setAgendaId(item.object_id)}>
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
<span><strong>{item.title}</strong><small>{humanize(item.state)}</small></span>
</button>
{canWrite && canReviseCommitteeRecord(item) ? <IconButton label="Edit agenda item" icon={<Pencil size={15} />} onClick={() => setEditor({ kind: "agenda_item", record: item })} /> : null}
</div>
))}
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
</div>
</WorkspaceSection>
{selectedAgenda ? (
<WorkspaceSection
title={`Votes - ${selectedAgenda.title}`}
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}>
<Vote size={16} aria-hidden="true" />
Add vote
</Button>
) : null}
>
<RecordRows
records={votes}
canEdit={(record) => canWrite && canReviseCommitteeRecord(record)}
onEdit={(record) => setEditor({ kind: "vote", record })}
detail={voteSummary}
secondaryAction={(record) => canFinalizeBallot && isProviderBallotReady(record) ? (
<IconButton
label={`Finalize ${record.title} from ballot provider`}
icon={<ShieldCheck size={15} />}
onClick={() => setBallotRecord(record)}
/>
) : null}
/>
</WorkspaceSection>
) : null}
<WorkspaceSection
title="Minutes"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}>
<FilePlus2 size={16} aria-hidden="true" />
Add minutes
</Button>
) : null}
>
<RecordRows records={minutes} canEdit={(record) => canWrite && canReviseCommitteeRecord(record)} onEdit={(record) => setEditor({ kind: "minute", record })} detail={(record) => `Record ${String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-")}`} />
</WorkspaceSection>
</PageScrollViewport>
</>
)}
</section>
</div>
</div>
{editor ? (
<CommitteeRecordDialog
settings={settings}
tenantId={tenantId}
kind={editor.kind}
parentId={editor.parentId}
record={editor.record}
open
onClose={() => setEditor(null)}
onSaved={() => void refresh()}
/>
) : null}
{ballotRecord ? (
<CommitteeBallotDialog
settings={settings}
record={ballotRecord}
open
onClose={() => setBallotRecord(null)}
onSaved={() => void refresh()}
/>
) : null}
</main>
);
}
function PanelHeading({ title, count }: { title: string; count: number }) {
return <div className="committee-panel-heading"><h2>{title}</h2><span>{count}</span></div>;
}
function RecordList({ records, selectedId, onSelect, secondary }: {
records: CommitteeRecord[];
selectedId: string;
onSelect: (id: string) => void;
secondary?: (record: CommitteeRecord) => string;
}) {
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
return <div className="committee-record-list">{records.map((record) => (
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
<strong>{record.title}</strong>
<span>{secondary?.(record) ?? humanize(record.state)}</span>
</button>
))}</div>;
}
function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
}
function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
records: CommitteeRecord[];
canEdit: (record: CommitteeRecord) => boolean;
onEdit: (record: CommitteeRecord) => void;
detail: (record: CommitteeRecord) => string;
secondaryAction?: (record: CommitteeRecord) => ReactNode;
}) {
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
return <div className="committee-record-rows">{records.map((record) => (
<div key={record.object_id}>
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
<span className="committee-row-actions">
{secondaryAction?.(record)}
{canEdit(record) ? <IconButton label={`Edit ${record.title}`} icon={<Pencil size={15} />} onClick={() => onEdit(record)} /> : null}
</span>
</div>
))}</div>;
}
function meetingSecondary(record: CommitteeRecord): string {
return `${formatDateTime(record.attributes.starts_at)} - ${humanize(record.state)}`;
}
function voteSummary(record: CommitteeRecord): string {
const cast = Number(record.attributes.cast_count ?? 0);
const eligible = Number(record.attributes.eligible_count ?? 0);
return `${humanize(String(record.attributes.method ?? "recorded"))}, ${cast}/${eligible} cast`;
}
function isProviderBallotReady(record: CommitteeRecord): boolean {
return record.state === "open" && Boolean(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): string {
if (!value) return "Date not set";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
}
function formatTime(value: unknown): string {
if (!value) return "-";
return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(new Date(String(value)));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}