feat: implement committee decision workspace
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/committee.css": "./src/styles/committee.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, committeeModule } from "./module";
|
||||
export * from "./api/committee";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user