feat: implement committee decision workspace
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user