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