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
+115
View File
@@ -0,0 +1,115 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type CommitteeObjectKind = "body" | "meeting" | "agenda_item" | "vote" | "minute";
export type CommitteeRecord = {
tenant_id: string;
object_kind: CommitteeObjectKind;
object_id: string;
revision: number;
state: string;
title: string;
parent_id?: string | null;
recorded_at: string;
change_reason: string;
attributes: Record<string, unknown>;
context?: Record<string, unknown> | null;
evidence: Array<Record<string, unknown>>;
record_refs: Array<Record<string, unknown>>;
};
export type CommitteeRecordList = {
records: CommitteeRecord[];
total: number;
offset: number;
limit: number;
};
export function listCommitteeRecords(
settings: ApiSettings,
kind: CommitteeObjectKind,
options: {
parentId?: string;
query?: string;
states?: string[];
limit?: number;
} = {},
signal?: AbortSignal
): Promise<CommitteeRecordList> {
return apiFetch<CommitteeRecordList>(
settings,
apiPath(`/api/v1/committee/workspace/${kind}`, {
parent_id: options.parentId,
query: options.query,
state: options.states,
limit: options.limit ?? 200
}),
{ signal }
);
}
export function saveCommitteeRecord(
settings: ApiSettings,
record: CommitteeRecord,
expectedRevision?: number
): Promise<CommitteeRecord> {
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/${record.object_kind}`,
{
method: "POST",
body: JSON.stringify({
record,
idempotency_key: crypto.randomUUID(),
expected_revision: expectedRevision
})
}
);
}
export function committeeRecordHistory(
settings: ApiSettings,
record: CommitteeRecord,
signal?: AbortSignal
): Promise<{ revisions: CommitteeRecord[] }> {
return apiFetch(
settings,
`/api/v1/committee/workspace/${record.object_kind}/${encodeURIComponent(record.object_id)}/history`,
{ signal }
);
}
export function finalizeProviderBallot(
settings: ApiSettings,
record: CommitteeRecord,
input: {
providerBallotRef: string;
approvalId: string;
changeReason: string;
}
): Promise<CommitteeRecord> {
const providerId = String(record.attributes.provider_id ?? "").trim();
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-provider`,
{
method: "POST",
body: JSON.stringify({
provider_id: providerId,
provider_ballot_ref: input.providerBallotRef.trim(),
approval_ref: {
kind: "approval",
owner_module: "approvals",
object_id: input.approvalId.trim(),
tenant_id: record.tenant_id,
version: "1"
},
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: input.changeReason.trim(),
idempotency_key: crypto.randomUUID()
})
}
);
}
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
type ApiSettings
} from "@govoplan/core-webui";
import {
finalizeProviderBallot,
type CommitteeRecord
} from "../../api/committee";
export default function CommitteeBallotDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CommitteeRecord;
open: boolean;
onClose: () => void;
onSaved: (record: CommitteeRecord) => void;
}) {
const [providerBallotRef, setProviderBallotRef] = useState("");
const [approvalId, setApprovalId] = useState("");
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setProviderBallotRef("");
setApprovalId("");
setChangeReason("Imported verified ballot aggregate.");
setError("");
}, [open, record.object_id]);
async function finalize() {
setBusy(true);
setError("");
try {
const saved = await finalizeProviderBallot(settings, record, {
providerBallotRef,
approvalId,
changeReason
});
onSaved(saved);
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
} finally {
setBusy(false);
}
}
const providerId = String(record.attributes.provider_id ?? "");
return (
<Dialog
open={open}
title="Finalize provider ballot"
onClose={onClose}
closeDisabled={busy}
portal
className="committee-ballot-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
onClick={() => void finalize()}
>
{busy ? "Importing" : "Finalize"}
</Button>
</>
}
>
<div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="committee-dialog-note">
Provider <strong>{providerId}</strong> returns only the verified aggregate result,
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN.
</p>
<FormField label="Provider ballot reference">
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
</FormField>
<FormField label="Approval ID">
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
</FormField>
<FormField label="Change reason">
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
</Dialog>
);
}
@@ -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());
}
@@ -0,0 +1,431 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
type ApiSettings
} from "@govoplan/core-webui";
import {
saveCommitteeRecord,
type CommitteeObjectKind,
type CommitteeRecord
} from "../../api/committee";
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
type Draft = {
title: string;
state: string;
changeReason: string;
organizationUnitId: string;
startsAt: string;
endsAt: string;
position: string;
subjectKind: string;
subjectId: string;
choices: string;
method: string;
eligibleCount: string;
castCount: string;
counts: Record<string, string>;
quorumMet: boolean;
approvalId: string;
evidenceId: string;
providerId: string;
contentRecordId: string;
decisionId: string;
};
export default function CommitteeRecordDialog({
settings,
tenantId,
kind,
parentId,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
open: boolean;
onClose: () => void;
onSaved: (record: CommitteeRecord) => void;
}) {
const [draft, setDraft] = useState<Draft>(() => draftFromRecord(kind, record));
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const choices = useMemo(
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
[draft.choices]
);
const stateOptions = useMemo(
() => committeeStateOptions(kind, record),
[kind, record]
);
useEffect(() => {
if (!open) return;
setDraft(draftFromRecord(kind, record));
setError("");
}, [kind, open, record]);
async function save() {
setBusy(true);
setError("");
try {
const payload = recordFromDraft({
tenantId,
kind,
parentId,
record,
draft,
choices
});
const saved = await saveCommitteeRecord(
settings,
payload,
record?.revision
);
onSaved(saved);
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title={`${record ? "Edit" : "New"} ${labelForKind(kind)}`}
onClose={onClose}
closeDisabled={busy}
portal
className="committee-record-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !draft.title.trim() || !draft.changeReason.trim()}
onClick={() => void save()}
>
{busy ? "Saving" : "Save"}
</Button>
</>
}
>
<div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<div className="committee-form-grid">
<FormField label="Title">
<input
value={draft.title}
maxLength={500}
disabled={busy}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</FormField>
<FormField label="State">
<select
value={draft.state}
disabled={busy}
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
>
{stateOptions.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</FormField>
</div>
{kind === "body" ? (
<FormField label="Responsible organization unit ID">
<input
value={draft.organizationUnitId}
disabled={busy}
onChange={(event) => setDraft({ ...draft, organizationUnitId: event.target.value })}
/>
</FormField>
) : null}
{kind === "meeting" ? (
<div className="committee-form-grid">
<FormField label="Starts">
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
</FormField>
<FormField label="Ends">
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
</FormField>
</div>
) : null}
{kind === "agenda_item" ? (
<>
<div className="committee-form-grid committee-form-grid-three">
<FormField label="Position">
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
</FormField>
<FormField label="Subject type">
<select value={draft.subjectKind} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectKind: event.target.value })}>
<option value="case">Case</option>
<option value="service">Service</option>
<option value="work_item">Work item</option>
<option value="record">Record</option>
</select>
</FormField>
<FormField label="Subject ID">
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
</FormField>
</div>
{draft.state === "decided" ? (
<FormField label="Formal Decision ID">
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
</FormField>
) : null}
</>
) : null}
{kind === "vote" ? (
<>
<div className="committee-form-grid committee-form-grid-three">
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
<option value="recorded">Recorded</option>
<option value="public">Public</option>
<option value="secret">Secret</option>
</select>
</FormField>
<FormField label="Eligible voters">
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
</FormField>
<FormField label="Ballot provider (optional)">
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField>
</div>
<FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField>
{draft.state === "closed" ? (
<div className="committee-vote-result-fields">
<div className="committee-form-grid">
<FormField label="Votes cast">
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
</FormField>
<FormField label="Quorum">
<select value={draft.quorumMet ? "met" : "not-met"} disabled={busy} onChange={(event) => setDraft({ ...draft, quorumMet: event.target.value === "met" })}>
<option value="met">Met</option>
<option value="not-met">Not met</option>
</select>
</FormField>
</div>
<div className="committee-count-grid">
{choices.map((choice) => (
<FormField key={choice} label={`${choice} votes`}>
<input
type="number"
min="0"
value={draft.counts[choice] ?? "0"}
disabled={busy}
onChange={(event) => setDraft({
...draft,
counts: { ...draft.counts, [choice]: event.target.value }
})}
/>
</FormField>
))}
</div>
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
</div>
) : null}
</>
) : null}
{kind === "minute" ? (
<>
<FormField label="Minutes record ID">
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
</FormField>
{draft.state === "accepted" || draft.state === "corrected" ? (
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
) : null}
</>
) : null}
<FormField label="Change reason">
<input
value={draft.changeReason}
maxLength={1000}
disabled={busy}
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
/>
</FormField>
</div>
</Dialog>
);
}
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return (
<div className="committee-form-grid">
<FormField label="Approval ID">
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
</FormField>
<FormField label="Evidence record ID">
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
</FormField>
</div>
);
}
function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | null): Draft {
const attributes = record?.attributes ?? {};
const startsAt = localDateTime(attributes.starts_at, 60);
const endsAt = localDateTime(attributes.ends_at, 120);
const subject = firstMapping(attributes.subject_refs);
const approval = mapping(attributes.approval_ref);
const content = mapping(attributes.content_ref);
const decision = mapping(attributes.decision_ref);
const organization = mapping(attributes.organization_unit_ref);
const counts = mapping(attributes.counts);
return {
title: record?.title ?? "",
state: record?.state ?? COMMITTEE_STATES[kind][0],
changeReason: record ? "" : `Created ${labelForKind(kind)}.`,
organizationUnitId: text(organization.object_id),
startsAt,
endsAt,
position: String(attributes.position ?? 1),
subjectKind: text(subject.kind) || "case",
subjectId: text(subject.object_id),
choices: array(attributes.choices).join(", ") || "yes, no, abstain",
method: text(attributes.method) || "recorded",
eligibleCount: String(attributes.eligible_count ?? 0),
castCount: String(attributes.cast_count ?? 0),
counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, String(value)])),
quorumMet: attributes.quorum_met !== false,
approvalId: text(approval.object_id),
evidenceId: text(firstMapping(record?.evidence).evidence_id),
providerId: text(attributes.provider_id),
contentRecordId: text(content.object_id),
decisionId: text(decision.object_id)
};
}
function recordFromDraft({ tenantId, kind, parentId, record, draft, choices }: {
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
draft: Draft;
choices: string[];
}): CommitteeRecord {
const attributes = attributesFromDraft(tenantId, kind, draft, choices);
const evidence = needsEvidence(kind, draft.state) && draft.evidenceId.trim()
? [{
kind: "record",
owner_module: "records",
evidence_id: draft.evidenceId.trim(),
tenant_id: tenantId,
version: "1",
captured_at: new Date().toISOString()
}]
: record?.evidence ?? [];
return {
tenant_id: tenantId,
object_kind: kind,
object_id: record?.object_id ?? crypto.randomUUID(),
revision: (record?.revision ?? 0) + 1,
state: draft.state,
title: draft.title.trim(),
parent_id: record?.parent_id ?? parentId ?? null,
recorded_at: new Date().toISOString(),
change_reason: draft.changeReason.trim(),
attributes,
context: record?.context ?? null,
evidence,
record_refs: record?.record_refs ?? []
};
}
function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft: Draft, choices: string[]): Record<string, unknown> {
if (kind === "body") return {
organization_unit_ref: reference("organization_unit", "organizations", draft.organizationUnitId, tenantId),
function_refs: []
};
if (kind === "meeting") return {
starts_at: new Date(draft.startsAt).toISOString(),
ends_at: new Date(draft.endsAt).toISOString()
};
if (kind === "agenda_item") return {
position: Number(draft.position),
subject_refs: [reference(draft.subjectKind, ownerForKind(draft.subjectKind), draft.subjectId, tenantId)],
...(draft.state === "decided" ? {
decision_ref: reference("decision", "decisions", draft.decisionId, tenantId)
} : {})
};
if (kind === "vote") return {
method: draft.method,
choices,
eligible_count: Number(draft.eligibleCount),
cast_count: Number(draft.castCount),
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
...(draft.state === "closed" ? {
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
quorum_met: draft.quorumMet,
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
return {
content_ref: reference("record", "records", draft.contentRecordId, tenantId),
...(draft.state === "accepted" || draft.state === "corrected" ? {
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
}
function reference(kind: string, owner: string, objectId: string, tenantId: string) {
return { kind, owner_module: owner, object_id: objectId.trim(), tenant_id: tenantId, version: "1" };
}
function ownerForKind(kind: string): string {
return { case: "cases", service: "services", work_item: "workflow_engine", record: "records" }[kind] ?? kind;
}
function needsEvidence(kind: CommitteeObjectKind, state: string): boolean {
return (kind === "vote" && state === "closed")
|| (kind === "minute" && ["accepted", "corrected"].includes(state));
}
function localDateTime(value: unknown, offsetMinutes: number): string {
const date = value ? new Date(String(value)) : new Date(Date.now() + offsetMinutes * 60_000);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function mapping(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function firstMapping(value: unknown): Record<string, unknown> {
return mapping(Array.isArray(value) ? value[0] : undefined);
}
function array(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function labelForKind(kind: CommitteeObjectKind): string {
return humanize(kind);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+60
View File
@@ -0,0 +1,60 @@
import type { CommitteeObjectKind, CommitteeRecord } from "../../api/committee";
export const COMMITTEE_STATES: Record<CommitteeObjectKind, string[]> = {
body: ["draft", "active", "suspended", "retired"],
meeting: ["draft", "scheduled", "open", "closed", "cancelled"],
agenda_item: ["draft", "scheduled", "deliberating", "decided", "withdrawn"],
vote: ["draft", "open", "closed", "cancelled"],
minute: ["draft", "proposed", "accepted", "corrected"]
};
const TRANSITIONS: Record<CommitteeObjectKind, Record<string, string[]>> = {
body: {
draft: ["draft", "active", "retired"],
active: ["active", "suspended", "retired"],
suspended: ["active", "suspended", "retired"],
retired: []
},
meeting: {
draft: ["draft", "scheduled", "cancelled"],
scheduled: ["scheduled", "open", "cancelled"],
open: ["open", "closed", "cancelled"],
closed: [],
cancelled: []
},
agenda_item: {
draft: ["draft", "scheduled", "withdrawn"],
scheduled: ["scheduled", "deliberating", "withdrawn"],
deliberating: ["deliberating", "decided", "withdrawn"],
decided: [],
withdrawn: []
},
vote: {
draft: ["draft", "open", "cancelled"],
open: ["open", "closed", "cancelled"],
closed: [],
cancelled: []
},
minute: {
draft: ["draft", "proposed"],
proposed: ["proposed", "accepted"],
accepted: ["corrected"],
corrected: ["corrected"]
}
};
export function committeeStateOptions(
kind: CommitteeObjectKind,
record?: CommitteeRecord | null
): string[] {
if (!record) return COMMITTEE_STATES[kind];
const options = TRANSITIONS[kind][record.state] ?? [];
const providerBound = kind === "vote" && Boolean(String(record.attributes.provider_id ?? "").trim());
const allowed = providerBound ? options.filter((state) => state !== "closed") : options;
return allowed.length > 0 ? allowed : [record.state];
}
export function canReviseCommitteeRecord(record: CommitteeRecord): boolean {
return (TRANSITIONS[record.object_kind][record.state] ?? []).length > 0;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, committeeModule } from "./module";
export * from "./api/committee";
+32
View File
@@ -0,0 +1,32 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/committee.css";
const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
export const committeeModule: PlatformWebModule = {
id: "committee",
name: "Committee",
version: "0.1.8",
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
navItems: [
{
to: "/committee",
label: "Committee",
iconName: "gavel",
anyOf: ["committee:workspace:read"],
order: 38
}
],
routes: [
{
path: "/committee",
anyOf: ["committee:workspace:read"],
order: 38,
render: (context) => createElement(CommitteePage, context)
}
]
};
export default committeeModule;
+326
View File
@@ -0,0 +1,326 @@
.committee-page,
.committee-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.committee-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.committee-toolbar {
display: flex;
align-items: center;
gap: 9px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.committee-search {
display: flex;
align-items: center;
gap: 8px;
width: min(460px, 100%);
margin-right: auto;
}
.committee-search input {
min-width: 140px;
flex: 1;
}
.committee-alert {
margin: 10px 16px 0;
}
.committee-workspace {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
flex: 1;
min-height: 0;
}
.committee-panel,
.committee-detail {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--border);
}
.committee-detail {
border-right: 0;
}
.committee-panel-heading,
.committee-detail-heading,
.committee-workspace-section > div:first-child {
display: flex;
align-items: center;
gap: 10px;
}
.committee-panel-heading {
min-height: 48px;
padding: 8px 13px;
border-bottom: 1px solid var(--border);
}
.committee-panel-heading h2,
.committee-workspace-section h2 {
margin: 0;
font-size: 0.92rem;
letter-spacing: 0;
}
.committee-panel-heading span {
margin-left: auto;
color: var(--text-soft);
font-size: 0.78rem;
}
.committee-panel-scroll,
.committee-detail-scroll {
flex: 1;
min-height: 0;
}
.committee-record-list {
display: flex;
flex-direction: column;
}
.committee-record-list > button {
display: flex;
min-height: 58px;
flex-direction: column;
gap: 3px;
padding: 9px 13px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.committee-record-list > button:hover,
.committee-record-list > button.is-selected,
.committee-agenda-list > div:hover,
.committee-agenda-list > div.is-selected {
background: var(--hover-bg);
}
.committee-record-list span,
.committee-agenda-list small,
.committee-record-rows small,
.committee-detail-heading span {
color: var(--text-soft);
font-size: 0.77rem;
}
.committee-panel-actions {
display: flex;
justify-content: flex-end;
gap: 7px;
padding: 9px;
border-top: 1px solid var(--border);
}
.committee-detail-heading {
min-height: 70px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.committee-detail-heading > div:first-child {
min-width: 0;
margin-right: auto;
}
.committee-detail-heading h1 {
overflow: hidden;
margin: 3px 0 0;
font-size: 1.08rem;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.committee-workspace-section {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.committee-workspace-section > div:first-child {
min-height: 36px;
margin-bottom: 8px;
}
.committee-workspace-section > div:first-child .btn {
margin-left: auto;
}
.committee-agenda-list,
.committee-record-rows {
border-top: 1px solid var(--border);
}
.committee-agenda-list > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 52px;
border-bottom: 1px solid var(--border);
}
.committee-agenda-list > div > button:first-child {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
min-height: 51px;
padding: 6px 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.committee-agenda-list > div > button:first-child > span:nth-child(2),
.committee-record-rows > div > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.committee-agenda-position {
color: var(--text-soft);
font-size: 0.82rem;
text-align: center;
}
.committee-record-rows > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.committee-row-actions {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 4px !important;
}
.committee-empty {
display: grid;
min-height: 180px;
place-items: center;
color: var(--text-soft);
}
.committee-empty.compact {
min-height: 72px;
}
.committee-record-dialog {
width: min(820px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 32px));
}
.committee-ballot-dialog {
width: min(620px, calc(100vw - 32px));
}
.committee-dialog-note {
margin: 0;
color: var(--text-soft);
line-height: 1.45;
}
.committee-record-form {
display: flex;
flex-direction: column;
gap: 13px;
}
.committee-form-grid,
.committee-count-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.committee-form-grid-three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.committee-count-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.committee-vote-result-fields {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
@media (max-width: 1050px) {
.committee-workspace {
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
}
}
@media (max-width: 760px) {
.committee-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.committee-search {
width: 100%;
}
.committee-workspace {
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.committee-body-panel {
grid-column: 1;
grid-row: 1;
border-bottom: 1px solid var(--border);
}
.committee-meeting-panel {
grid-column: 1;
grid-row: 2;
}
.committee-detail {
grid-column: 2;
grid-row: 1 / span 2;
}
.committee-form-grid,
.committee-form-grid-three {
grid-template-columns: 1fr;
}
}