Implement governed voting module

This commit is contained in:
2026-08-01 20:57:25 +02:00
commit c176c5cfcb
26 changed files with 3758 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@govoplan/voting-webui",
"version": "0.1.14",
"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/voting.css": "./src/styles/voting.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type VotingOption = { key: string; label: string; description?: string | null };
export type VotingElector = { subject_id: string; label?: string | null; weight: number; provenance: Record<string, unknown> };
export type VotingBallot = {
id: string;
revision: number;
state: "draft" | "open" | "closed" | "certified" | "challenged" | "annulled";
title: string;
description?: string | null;
method: "single_choice" | "approval" | "yes_no_abstain";
assurance_profile: "recorded" | "confidential" | "secret" | "external_certified";
options: VotingOption[];
electorate: VotingElector[];
context: { module?: string | null; resource_type?: string | null; resource_id?: string | null };
quorum_weight: number;
threshold_numerator: number;
threshold_denominator: number;
allow_replacement: boolean;
opens_at?: string | null;
closes_at?: string | null;
provider_id?: string | null;
provider_ballot_ref?: string | null;
definition_sha256?: string | null;
electorate_sha256?: string | null;
result?: VotingResult | null;
certification?: Record<string, unknown> | null;
metadata: Record<string, unknown>;
};
export type VotingResult = {
counts: Record<string, number>;
weighted_counts: Record<string, number>;
cast_count: number;
cast_weight: number;
eligible_count: number;
eligible_weight: number;
quorum_met: boolean;
threshold_met: boolean;
winning_options: string[];
result_sha256: string;
evidence: Array<Record<string, unknown>>;
};
export type VotingBallotDraft = Omit<VotingBallot, "id" | "revision" | "state" | "context" | "definition_sha256" | "electorate_sha256" | "result" | "certification"> & {
context_module?: string | null;
context_resource_type?: string | null;
context_resource_id?: string | null;
};
export type VotingEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
export function listVotingBallots(settings: ApiSettings, state?: string, signal?: AbortSignal): Promise<{ ballots: VotingBallot[] }> {
return apiFetch(settings, apiPath("/api/v1/voting", { state, limit: 200 }), { signal });
}
export function getVotingBallot(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, { signal });
}
export function listVotingHistory(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise<VotingEvent[]> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/history`, { signal });
}
export function createVotingBallot(settings: ApiSettings, ballot: VotingBallotDraft): Promise<VotingBallot> {
return apiFetch(settings, "/api/v1/voting", {
method: "POST",
body: JSON.stringify({ ballot, idempotency_key: crypto.randomUUID() })
});
}
export function updateVotingBallot(settings: ApiSettings, ballotId: string, revision: number, ballot: VotingBallotDraft): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, {
method: "PUT",
body: JSON.stringify({ ballot, expected_revision: revision, idempotency_key: crypto.randomUUID() })
});
}
export function transitionVotingBallot(
settings: ApiSettings,
ballot: VotingBallot,
action: "open" | "close" | "certify",
extra: Record<string, unknown> = {}
): Promise<VotingBallot | { ballot: VotingBallot; result: VotingResult }> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, {
method: "POST",
body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), ...extra })
});
}
export function reasonedVotingTransition(settings: ApiSettings, ballot: VotingBallot, action: "challenge" | "annul", reason: string): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, {
method: "POST",
body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), reason })
});
}
export function castVotingBallot(settings: ApiSettings, ballotId: string, selections: string[]): Promise<{ receipt_sha256: string; replaced_previous: boolean; replayed: boolean }> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/cast`, {
method: "POST",
body: JSON.stringify({ selections, idempotency_key: crypto.randomUUID() })
});
}
@@ -0,0 +1,198 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
createVotingBallot,
updateVotingBallot,
type VotingBallot,
type VotingBallotDraft
} from "../../api/voting";
export default function VotingBallotDialog({
open,
settings,
ballot,
currentAccountId,
onClose,
onSaved
}: {
open: boolean;
settings: ApiSettings;
ballot: VotingBallot | null;
currentAccountId: string;
onClose: () => void;
onSaved: (ballot: VotingBallot) => void;
}) {
const [draft, setDraft] = useState<VotingBallotDraft>(() => initialDraft(currentAccountId, ballot));
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setDraft(initialDraft(currentAccountId, ballot));
setBusy(false);
setError("");
}, [ballot, currentAccountId, open]);
const valid = useMemo(() => Boolean(
draft.title.trim()
&& draft.options.length >= 2
&& draft.options.every((item) => item.key.trim() && item.label.trim())
&& draft.electorate.length > 0
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0)
&& (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim()))
), [draft]);
async function save() {
if (!valid) return;
setBusy(true);
setError("");
const payload: VotingBallotDraft = {
...draft,
title: draft.title.trim(),
description: draft.description?.trim() || null,
options: draft.options.map((item) => ({ ...item, key: item.key.trim(), label: item.label.trim(), description: item.description?.trim() || null })),
electorate: draft.electorate.map((item) => ({ ...item, subject_id: item.subject_id.trim(), label: item.label?.trim() || null })),
provider_id: draft.assurance_profile === "recorded" ? null : draft.provider_id?.trim() || null,
provider_ballot_ref: draft.assurance_profile === "recorded" ? null : draft.provider_ballot_ref?.trim() || null
};
try {
const saved = ballot
? await updateVotingBallot(settings, ballot.id, ballot.revision, payload)
: await createVotingBallot(settings, payload);
onSaved(saved);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The ballot could not be saved.");
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title={ballot ? `Edit ${ballot.title}` : "New ballot"}
onClose={onClose}
closeDisabled={busy}
portal
className="voting-ballot-dialog"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}>{busy ? "Saving" : "Save ballot"}</Button>
</>
}>
<div className="voting-ballot-editor">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="voting-editor-grid">
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value as VotingBallotDraft["method"] })}>
<option value="single_choice">Single choice</option>
<option value="approval">Approval</option>
<option value="yes_no_abstain">Yes / no / abstain</option>
</select>
</FormField>
<FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="Assurance profile">
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => setDraft({ ...draft, assurance_profile: event.target.value as VotingBallotDraft["assurance_profile"] })}>
<option value="recorded">Recorded</option>
<option value="confidential">Confidential provider</option>
<option value="secret">Secret provider</option>
<option value="external_certified">Externally certified</option>
</select>
</FormField>
<FormField label="Quorum weight"><input type="number" min={0} value={draft.quorum_weight} disabled={busy} onChange={(event) => setDraft({ ...draft, quorum_weight: Number(event.target.value) })} /></FormField>
<FormField label="Threshold numerator"><input type="number" min={1} value={draft.threshold_numerator} disabled={busy} onChange={(event) => setDraft({ ...draft, threshold_numerator: Number(event.target.value) })} /></FormField>
<FormField label="Threshold denominator"><input type="number" min={1} value={draft.threshold_denominator} disabled={busy} onChange={(event) => setDraft({ ...draft, threshold_denominator: Number(event.target.value) })} /></FormField>
<div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div>
{draft.assurance_profile !== "recorded" && <>
<FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
<FormField label="Provider ballot reference"><input value={draft.provider_ballot_ref ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })} /></FormField>
</>}
</div>
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
<div className="voting-editor-list">
{draft.options.map((option, index) => <div className="voting-option-row" key={`${index}:${option.key}`}>
<FormField label="Key"><input value={option.key} disabled={busy} onChange={(event) => patchOption(index, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={option.label} disabled={busy} onChange={(event) => patchOption(index, { label: event.target.value })} /></FormField>
<FormField label="Description"><input value={option.description ?? ""} disabled={busy} onChange={(event) => patchOption(index, { description: event.target.value })} /></FormField>
<IconButton label={`Remove ${option.label || "option"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.options.length <= 2} onClick={() => setDraft({ ...draft, options: draft.options.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
<EditorHeading title="Electorate" onAdd={() => setDraft({ ...draft, electorate: [...draft.electorate, { subject_id: "", label: "", weight: 1, provenance: {} }] })} disabled={busy} />
<div className="voting-editor-list">
{draft.electorate.map((elector, index) => <div className="voting-elector-row" key={`${index}:${elector.subject_id}`}>
<FormField label="Account id"><input value={elector.subject_id} disabled={busy} onChange={(event) => patchElector(index, { subject_id: event.target.value })} /></FormField>
<FormField label="Label"><input value={elector.label ?? ""} disabled={busy} onChange={(event) => patchElector(index, { label: event.target.value })} /></FormField>
<FormField label="Weight"><input type="number" min={1} value={elector.weight} disabled={busy} onChange={(event) => patchElector(index, { weight: Number(event.target.value) })} /></FormField>
<IconButton label={`Remove ${elector.label || "elector"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.electorate.length <= 1} onClick={() => setDraft({ ...draft, electorate: draft.electorate.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
</div>
</Dialog>
);
function patchOption(index: number, patch: Partial<VotingBallotDraft["options"][number]>) {
setDraft((current) => ({ ...current, options: current.options.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
function patchElector(index: number, patch: Partial<VotingBallotDraft["electorate"][number]>) {
setDraft((current) => ({ ...current, electorate: current.electorate.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
}
function EditorHeading({ title, onAdd, disabled }: { title: string; onAdd: () => void; disabled: boolean }) {
return <div className="voting-editor-heading"><h3>{title}</h3><Button onClick={onAdd} disabled={disabled}><Plus size={16} aria-hidden="true" />Add</Button></div>;
}
function initialDraft(accountId: string, ballot: VotingBallot | null): VotingBallotDraft {
if (ballot) return {
title: ballot.title,
description: ballot.description,
method: ballot.method,
assurance_profile: ballot.assurance_profile,
options: structuredClone(ballot.options),
electorate: structuredClone(ballot.electorate),
context_module: ballot.context?.module,
context_resource_type: ballot.context?.resource_type,
context_resource_id: ballot.context?.resource_id,
quorum_weight: ballot.quorum_weight,
threshold_numerator: ballot.threshold_numerator,
threshold_denominator: ballot.threshold_denominator,
allow_replacement: ballot.allow_replacement,
opens_at: ballot.opens_at,
closes_at: ballot.closes_at,
provider_id: ballot.provider_id,
provider_ballot_ref: ballot.provider_ballot_ref,
metadata: { ...ballot.metadata }
};
return {
title: "",
description: "",
method: "single_choice",
assurance_profile: "recorded",
options: [{ key: "yes", label: "Yes" }, { key: "no", label: "No" }],
electorate: [{ subject_id: accountId, label: "", weight: 1, provenance: {} }],
quorum_weight: 0,
threshold_numerator: 1,
threshold_denominator: 2,
allow_replacement: true,
opens_at: null,
closes_at: null,
provider_id: null,
provider_ballot_ref: null,
metadata: {}
};
}
+287
View File
@@ -0,0 +1,287 @@
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
castVotingBallot,
getVotingBallot,
listVotingBallots,
listVotingHistory,
reasonedVotingTransition,
transitionVotingBallot,
type VotingBallot,
type VotingEvent
} from "../../api/voting";
import VotingBallotDialog from "./VotingBallotDialog";
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
const [items, setItems] = useState<VotingBallot[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<VotingBallot | null>(null);
const [history, setHistory] = useState<VotingEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [editing, setEditing] = useState<VotingBallot | "new" | null>(null);
const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null);
const [selections, setSelections] = useState<string[]>([]);
const canManage = hasScope(auth, "voting:ballot:manage");
const canCast = hasScope(auth, "voting:ballot:cast");
const canCertify = hasScope(auth, "voting:ballot:certify");
const canAdmin = hasScope(auth, "voting:ballot:admin");
const loadList = useCallback(async (signal?: AbortSignal, preferredId?: string) => {
setLoading(true);
setError("");
try {
const result = await listVotingBallots(settings, undefined, signal);
setItems(result.ballots);
setSelectedId((current) => preferredId ?? current ?? result.ballots[0]?.id ?? null);
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
const controller = new AbortController();
void loadList(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Ballots could not be loaded."));
});
return () => controller.abort();
}, [loadList]);
useEffect(() => {
if (!selectedId) {
setSelected(null);
setHistory([]);
return;
}
const controller = new AbortController();
Promise.all([
getVotingBallot(settings, selectedId, controller.signal),
listVotingHistory(settings, selectedId, controller.signal)
]).then(([ballot, events]) => {
setSelected(ballot);
setHistory(events);
setSelections([]);
}).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Ballot details could not be loaded."));
});
return () => controller.abort();
}, [selectedId, settings]);
const eligible = useMemo(() => Boolean(
selected?.electorate.some((item) => item.subject_id === auth.account.id)
), [auth.account.id, selected]);
async function run(action: "open" | "close" | "certify") {
if (!selected) return;
setBusy(true);
setError("");
setNotice("");
try {
await transitionVotingBallot(settings, selected, action, action === "certify" ? { evidence: [] } : {});
await reloadSelected(`${humanize(action)} completed.`);
} catch (reason) {
setError(message(reason, `The ballot could not be ${action}ed.`));
} finally {
setBusy(false);
}
}
async function cast() {
if (!selected || selections.length === 0) return;
setBusy(true);
setError("");
setNotice("");
try {
const receipt = await castVotingBallot(settings, selected.id, selections);
setNotice(`${receipt.replaced_previous ? "Vote replaced" : "Vote recorded"}. Receipt ${receipt.receipt_sha256.slice(0, 12)}...`);
const events = await listVotingHistory(settings, selected.id);
setHistory(events);
} catch (reason) {
setError(message(reason, "The vote could not be recorded."));
} finally {
setBusy(false);
}
}
async function reloadSelected(success?: string) {
if (!selectedId) return;
const [ballot, events] = await Promise.all([
getVotingBallot(settings, selectedId),
listVotingHistory(settings, selectedId)
]);
setSelected(ballot);
setHistory(events);
await loadList(undefined, selectedId);
if (success) setNotice(success);
}
return (
<main className="voting-page">
<div className="voting-shell">
<aside className="voting-catalogue">
<div className="voting-toolbar">
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void loadList()} />
{canManage && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
</div>
<PageScrollViewport className="voting-list-viewport">
{loading && <LoadingIndicator label="Loading ballots" />}
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
<div className="voting-list" role="list">
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
</button>)}
</div>
</PageScrollViewport>
</aside>
<section className="voting-workspace">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!selected && !loading && <div className="voting-empty">Select a ballot.</div>}
{selected && <PageScrollViewport className="voting-detail-viewport">
<div className="voting-detail-heading">
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
<div className="voting-actions">
<StatusBadge status={statusTone(selected.state)} label={humanize(selected.state)} />
{canManage && selected.state === "draft" && <IconButton label={`Edit ${selected.title}`} icon={<Pencil size={16} />} disabled={busy} onClick={() => setEditing(selected)} />}
{canManage && selected.state === "draft" && <Button variant="primary" disabled={busy} onClick={() => void run("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
{canManage && selected.state === "open" && <Button variant="primary" disabled={busy} onClick={() => void run("close")}><XCircle size={16} aria-hidden="true" />Close and tally</Button>}
{canCertify && selected.state === "closed" && <Button variant="primary" disabled={busy} onClick={() => void run("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</Button>}
{canCertify && ["closed", "certified"].includes(selected.state) && <Button disabled={busy} onClick={() => setReasonAction("challenge")}>Challenge</Button>}
{canAdmin && selected.state !== "annulled" && <Button variant="danger" disabled={busy} onClick={() => setReasonAction("annul")}>Annul</Button>}
</div>
</div>
<div className="voting-metrics">
<Metric label="Electors" value={selected.electorate.length} />
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
<Metric label="Quorum" value={selected.quorum_weight} />
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
</div>
{selected.description && <p className="voting-description">{selected.description}</p>}
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
<h3>Cast vote</h3>
<div className="voting-options">
{selected.options.map((option) => <label key={option.key}>
<input
type={selected.method === "approval" ? "checkbox" : "radio"}
name="voting-selection"
checked={selections.includes(option.key)}
onChange={(event) => setSelections((current) => selected.method === "approval"
? event.target.checked ? [...current, option.key] : current.filter((item) => item !== option.key)
: [option.key])}
/>
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
</label>)}
</div>
<Button variant="primary" disabled={busy || selections.length === 0} onClick={() => void cast()}>Submit vote</Button>
</section>}
<section className="voting-section">
<h3>Options</h3>
<div className="voting-option-results">
{selected.options.map((option) => <div key={option.key}>
<span><strong>{option.label}</strong><small>{option.key}</small></span>
{selected.result && <span>{selected.result.counts[option.key] ?? 0} votes / {selected.result.weighted_counts[option.key] ?? 0} weight</span>}
</div>)}
</div>
</section>
{selected.result && <section className="voting-section">
<h3>Result</h3>
<div className="voting-metrics">
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
</div>
<Hash label="Result hash" value={selected.result.result_sha256} />
</section>}
<section className="voting-section voting-assurance">
<h3>Frozen assurance</h3>
<Hash label="Definition" value={selected.definition_sha256} />
<Hash label="Electorate" value={selected.electorate_sha256} />
</section>
<section className="voting-section">
<h3>History</h3>
<div className="voting-history">
{history.map((item) => <div key={item.sequence}><span>{item.sequence}</span><strong>{humanize(item.event_type)}</strong><time>{new Date(item.recorded_at).toLocaleString()}</time></div>)}
</div>
</section>
</PageScrollViewport>}
</section>
</div>
{editing && <VotingBallotDialog
open
settings={settings}
ballot={editing === "new" ? null : editing}
currentAccountId={auth.account.id}
onClose={() => setEditing(null)}
onSaved={(ballot) => {
setEditing(null);
setSelectedId(ballot.id);
void loadList(undefined, ballot.id);
}}
/>}
{selected && reasonAction && <ReasonDialog
action={reasonAction}
busy={busy}
onClose={() => setReasonAction(null)}
onConfirm={async (reason) => {
setBusy(true);
setError("");
try {
await reasonedVotingTransition(settings, selected, reasonAction, reason);
setReasonAction(null);
await reloadSelected(`${humanize(reasonAction)} recorded.`);
} catch (failure) {
setError(message(failure, "The transition could not be recorded."));
} finally {
setBusy(false);
}
}}
/>}
</main>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function Hash({ label, value }: { label: string; value?: string | null }) {
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
}
function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise<void> }) {
const [reason, setReason] = useState("");
return <Dialog open title={`${humanize(action)} ballot`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={action === "annul" ? "danger" : "primary"} disabled={busy || !reason.trim()} onClick={() => void onConfirm(reason.trim())}>Confirm</Button></>}>
<FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>
</Dialog>;
}
function statusTone(state: VotingBallot["state"]): "active" | "inactive" | "warning" {
if (state === "open" || state === "certified") return "active";
if (state === "challenged") return "warning";
return "inactive";
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function message(reason: unknown, fallback: string): string {
return reason instanceof Error ? reason.message : fallback;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, votingModule } from "./module";
export * from "./api/voting";
+40
View File
@@ -0,0 +1,40 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/voting.css";
const VotingPage = lazy(() => import("./features/voting/VotingPage"));
export const votingModule: PlatformWebModule = {
id: "voting",
label: "Voting",
version: "0.1.14",
dependencies: ["access"],
optionalDependencies: ["committee", "decisions", "encryption", "forms", "identity_trust", "policy", "reporting", "workflow_engine"],
routes: [
{
path: "/voting",
anyOf: ["voting:ballot:read"],
order: 39,
surfaceId: "voting.catalogue",
render: (context) => createElement(VotingPage, context)
}
],
navItems: [
{
to: "/voting",
label: "Voting",
iconName: "vote",
anyOf: ["voting:ballot:read"],
order: 39,
surfaceId: "voting.navigation"
}
],
viewSurfaces: [
{ id: "voting.navigation", moduleId: "voting", kind: "navigation", label: "Voting navigation", order: 10 },
{ id: "voting.catalogue", moduleId: "voting", kind: "route", label: "Voting ballot catalogue", order: 20 },
{ id: "voting.ballot", moduleId: "voting", kind: "section", label: "Voting ballot workspace", parentId: "voting.catalogue", order: 30 }
]
};
export default votingModule;
+277
View File
@@ -0,0 +1,277 @@
.voting-page {
min-height: 0;
height: 100%;
overflow: hidden;
}
.voting-shell {
display: grid;
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--surface, #fff);
}
.voting-catalogue {
display: flex;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--border-color, #d8dde3);
}
.voting-toolbar,
.voting-detail-heading,
.voting-actions,
.voting-editor-heading {
display: flex;
align-items: center;
gap: 8px;
}
.voting-toolbar {
min-height: 50px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-list-viewport,
.voting-detail-viewport {
min-height: 0;
flex: 1;
}
.voting-list {
display: flex;
flex-direction: column;
padding: 6px;
}
.voting-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 52px;
padding: 7px 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.voting-list-row:hover,
.voting-list-row.is-selected {
background: var(--hover-bg, rgba(54, 99, 135, 0.1));
}
.voting-list-row > span:first-child,
.voting-option-results > div > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.voting-list-row small,
.voting-option-results small {
color: var(--text-muted, #65717e);
}
.voting-workspace {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.voting-detail-viewport > div {
padding: 16px 20px 28px;
}
.voting-detail-heading {
justify-content: space-between;
min-height: 44px;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-detail-heading h2,
.voting-editor-heading h3,
.voting-section h3,
.voting-cast-panel h3 {
margin: 0;
font-size: 1rem;
letter-spacing: 0;
}
.voting-detail-heading > div:first-child span {
color: var(--text-muted, #65717e);
font-size: 0.82rem;
}
.voting-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.voting-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
margin: 16px 0;
}
.voting-metrics > div {
display: flex;
min-width: 0;
flex-direction: column;
padding: 10px 12px;
border: 1px solid var(--border-color, #d8dde3);
border-radius: 4px;
}
.voting-metrics span,
.voting-hash span {
color: var(--text-muted, #65717e);
font-size: 0.75rem;
text-transform: uppercase;
}
.voting-description {
max-width: 80ch;
}
.voting-section,
.voting-cast-panel {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border-color, #d8dde3);
}
.voting-options,
.voting-option-results,
.voting-history {
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.voting-options label,
.voting-option-results > div,
.voting-history > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 7px 9px;
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
}
.voting-options label > span {
display: flex;
flex-direction: column;
}
.voting-history > div {
grid-template-columns: 32px minmax(0, 1fr) auto;
}
.voting-history time {
color: var(--text-muted, #65717e);
font-size: 0.82rem;
}
.voting-hash {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
align-items: center;
gap: 10px;
margin-top: 8px;
}
.voting-hash code {
overflow: hidden;
text-overflow: ellipsis;
}
.voting-empty {
padding: 24px;
color: var(--text-muted, #65717e);
}
.voting-ballot-dialog {
width: min(1040px, calc(100vw - 32px));
height: min(820px, calc(100vh - 32px));
}
.voting-ballot-editor {
display: flex;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: auto;
}
.voting-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.voting-editor-wide {
grid-column: 1 / -1;
}
.voting-editor-toggle {
display: flex;
align-items: flex-end;
padding-bottom: 5px;
}
.voting-editor-heading {
justify-content: space-between;
margin-top: 8px;
}
.voting-editor-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.voting-option-row,
.voting-elector-row {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(160px, 1fr) minmax(180px, 1.2fr) 34px;
align-items: end;
gap: 8px;
}
.voting-elector-row {
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
}
@media (max-width: 800px) {
.voting-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(160px, 34%) minmax(0, 1fr);
}
.voting-catalogue {
border-right: 0;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-metrics,
.voting-editor-grid,
.voting-option-row,
.voting-elector-row {
grid-template-columns: 1fr;
}
.voting-editor-wide {
grid-column: auto;
}
}