Files
govoplan-committee/webui/src/features/committee/CommitteePage.tsx
T
zemion 1d6eef386d fix(ui): align contextual documentation with headings
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.
2026-09-09 02:03:35 +02:00

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}`;
}