Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
500 lines
21 KiB
TypeScript
500 lines
21 KiB
TypeScript
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<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, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}`
|
|
: "",
|
|
[language, selectedMeeting]
|
|
);
|
|
|
|
return (
|
|
<main className="committee-page">
|
|
<WorkspaceFrame className="committee-shell" label="Committee workspace" interfaceId="committee.workspace" helpContextId="committee.page.workspace" helpModuleId="committee">
|
|
<WorkspaceActionBar
|
|
title="Committee"
|
|
titleHelp={<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />}
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void refresh(), loading }}
|
|
className="committee-toolbar"
|
|
contextActions={<FilterBar as="form" surface="control" wrap="never" width="compact" 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" />
|
|
</FilterBar>}
|
|
createAction={<Button
|
|
variant="primary"
|
|
disabled={!canWrite || loading}
|
|
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
|
|
onClick={() => setEditor({ kind: "body" })}
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
New body
|
|
</Button>}
|
|
/>
|
|
|
|
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
|
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
|
{!canWrite ? (
|
|
<ActionBlockerHint
|
|
tone="info"
|
|
reason={{
|
|
summary: "No Committee management permission",
|
|
details: COMMITTEE_INTERFACE_I18N.writeReason,
|
|
requiredAction: COMMITTEE_INTERFACE_I18N.permissionAction,
|
|
actor: COMMITTEE_INTERFACE_I18N.permissionActor,
|
|
target: COMMITTEE_INTERFACE_I18N.permissionDestination
|
|
}}
|
|
labels={{
|
|
requiredAction: COMMITTEE_INTERFACE_I18N.requiredAction,
|
|
actor: COMMITTEE_INTERFACE_I18N.actor,
|
|
target: COMMITTEE_INTERFACE_I18N.destination
|
|
}}
|
|
documentation={COMMITTEE_DOCUMENTATION}
|
|
/>
|
|
) : 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 ? (
|
|
<div className="committee-panel-actions">
|
|
<IconButton
|
|
label="Edit body"
|
|
icon={<Pencil size={16} />}
|
|
disabled={!canWrite || !canReviseCommitteeRecord(selectedBody)}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedBody) })}
|
|
onClick={() => setEditor({ kind: "body", record: selectedBody })}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
disabled={!canWrite}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
|
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={(record) => meetingSecondary(record, language, translateText)} />
|
|
</PageScrollViewport>
|
|
</section>
|
|
|
|
<section className="committee-detail" aria-label="Meeting workspace">
|
|
{!selectedMeeting ? (
|
|
<StatePanel size="fill" title="Meetings" description="Select or create a meeting." />
|
|
) : (
|
|
<>
|
|
<div className="committee-detail-heading">
|
|
<div>
|
|
<span>{meetingTime}</span>
|
|
<h1>{selectedMeeting.title}</h1>
|
|
</div>
|
|
<StatusBadge status={statusTone(selectedMeeting.state)} label={stateLabel(selectedMeeting.state)} />
|
|
<IconButton
|
|
label="Edit meeting"
|
|
icon={<Pencil size={16} />}
|
|
disabled={!canWrite || !canReviseCommitteeRecord(selectedMeeting)}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedMeeting) })}
|
|
onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })}
|
|
/>
|
|
</div>
|
|
<PageScrollViewport className="committee-detail-scroll">
|
|
<WorkspaceSection
|
|
title="Agenda"
|
|
action={(
|
|
<Button
|
|
variant="ghost"
|
|
disabled={!canWrite}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
|
onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}
|
|
>
|
|
<ListPlus size={16} aria-hidden="true" />
|
|
Add item
|
|
</Button>
|
|
)}
|
|
>
|
|
<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>{stateLabel(item.state)}</small></span>
|
|
</button>
|
|
<IconButton
|
|
label="Edit agenda item"
|
|
icon={<Pencil size={15} />}
|
|
disabled={!canWrite || !canReviseCommitteeRecord(item)}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(item) })}
|
|
onClick={() => setEditor({ kind: "agenda_item", record: item })}
|
|
/>
|
|
</div>
|
|
))}
|
|
{agendaItems.length === 0 ? <StatePanel size="inline" description="No agenda items." /> : null}
|
|
</div>
|
|
</WorkspaceSection>
|
|
|
|
{selectedAgenda ? (
|
|
<WorkspaceSection
|
|
title={i18nMessage("i18n:govoplan-committee.votes_for", { title: selectedAgenda.title })}
|
|
action={(
|
|
<Button
|
|
variant="ghost"
|
|
disabled={!canWrite}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
|
onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}
|
|
>
|
|
<Vote size={16} aria-hidden="true" />
|
|
Add vote
|
|
</Button>
|
|
)}
|
|
>
|
|
<RecordRows
|
|
records={votes}
|
|
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
|
onEdit={(record) => setEditor({ kind: "vote", record })}
|
|
detail={(record) => voteSummary(record, translateText)}
|
|
secondaryAction={(record) => isProviderBallotReady(record) ? (
|
|
<IconButton
|
|
label={i18nMessage("i18n:govoplan-committee.finalize_provider_record", { title: record.title })}
|
|
icon={<ShieldCheck size={15} />}
|
|
disabled={!canFinalizeBallot}
|
|
disabledReason={!canFinalizeBallot ? COMMITTEE_INTERFACE_I18N.finalizeReason : undefined}
|
|
onClick={() => setBallotRecord(record)}
|
|
/>
|
|
) : null}
|
|
/>
|
|
</WorkspaceSection>
|
|
) : null}
|
|
|
|
<WorkspaceSection
|
|
title="Minutes"
|
|
action={(
|
|
<Button
|
|
variant="ghost"
|
|
disabled={!canWrite}
|
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
|
onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}
|
|
>
|
|
<FilePlus2 size={16} aria-hidden="true" />
|
|
Add minutes
|
|
</Button>
|
|
)}
|
|
>
|
|
<RecordRows
|
|
records={minutes}
|
|
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
|
onEdit={(record) => setEditor({ kind: "minute", record })}
|
|
detail={(record) => i18nMessage("i18n:govoplan-committee.record_reference", { id: String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-") })}
|
|
/>
|
|
</WorkspaceSection>
|
|
</PageScrollViewport>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</WorkspaceFrame>
|
|
|
|
{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 <StatePanel size="inline" description="No records." />;
|
|
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) ?? stateLabel(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, 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 <StatePanel size="inline" description="No records." />;
|
|
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={stateLabel(record.state)} />
|
|
<span className="committee-row-actions">
|
|
{secondaryAction?.(record)}
|
|
<IconButton
|
|
label={i18nMessage("i18n:govoplan-committee.edit_record", { title: record.title })}
|
|
icon={<Pencil size={15} />}
|
|
disabled={Boolean(editDisabledReason(record))}
|
|
disabledReason={editDisabledReason(record)}
|
|
onClick={() => onEdit(record)}
|
|
/>
|
|
</span>
|
|
</div>
|
|
))}</div>;
|
|
}
|
|
|
|
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}`;
|
|
}
|