Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
353 lines
18 KiB
TypeScript
353 lines
18 KiB
TypeScript
import { CheckCircle2, Pencil, Plus, ShieldCheck, XCircle } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ActionBlockerHint,
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
FormField,
|
|
IconButton,
|
|
LoadingIndicator,
|
|
MetricCard,
|
|
MetricGrid,
|
|
PageScrollViewport,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
SelectionListItemContent,
|
|
StatePanel,
|
|
StatusBadge,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
WorkspaceLayout,
|
|
hasScope,
|
|
i18nMessage,
|
|
usePlatformLanguage,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
type PlatformRouteContext
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
castVotingBallot,
|
|
getVotingBallot,
|
|
listVotingBallots,
|
|
listVotingHistory,
|
|
reasonedVotingTransition,
|
|
transitionVotingBallot,
|
|
type VotingBallot,
|
|
type VotingEvent
|
|
} from "../../api/voting";
|
|
import VotingBallotDialog from "./VotingBallotDialog";
|
|
import { VOTING_DOCUMENTATION, VOTING_FIELD_DOCUMENTATION, VOTING_I18N } from "./interfacePatterns";
|
|
|
|
|
|
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|
const { language, translateText } = usePlatformLanguage();
|
|
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 [pendingTransition, setPendingTransition] = useState<"open" | "close" | "certify" | null>(null);
|
|
const [confirmingCast, setConfirmingCast] = useState(false);
|
|
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 currentAccountId = auth.principal?.account_id || auth.user.account_id;
|
|
|
|
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 === currentAccountId)
|
|
), [currentAccountId, 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 (
|
|
<WorkspaceFrame as="main" className="voting-page" label="Ballots" interfaceId="voting.workspace" helpContextId="voting.workspace" helpModuleId="voting">
|
|
<WorkspaceActionBar
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void loadList().catch((reason) => setError(message(reason, "Ballots could not be loaded."))), loading: loading || busy, label: "Refresh ballots" }}
|
|
createAction={<Button variant="primary" disabled={!canManage || busy} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
|
|
title="Ballots"
|
|
titleLevel={1}
|
|
titleHelp={<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />}
|
|
/>
|
|
<WorkspaceLayout
|
|
variant="split"
|
|
primarySize="default"
|
|
primaryScrollable={false}
|
|
contentScrollable={false}
|
|
surface="contained"
|
|
primaryClassName="voting-catalogue"
|
|
contentClassName="voting-workspace"
|
|
primaryLabel="Ballots"
|
|
contentLabel="Ballot details"
|
|
interfaceId="voting.workspace"
|
|
helpContextId="voting.workspace"
|
|
helpModuleId="voting"
|
|
primary={<>
|
|
<PageScrollViewport className="voting-list-viewport">
|
|
{loading && <LoadingIndicator label="Loading ballots" />}
|
|
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
|
<SelectionList variant="navigation" label="Ballots">
|
|
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
|
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
|
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
|
</SelectionListItem>)}
|
|
</SelectionList>
|
|
</PageScrollViewport>
|
|
</>}
|
|
>
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
|
{!selected && !loading && <StatePanel size="fill" title="Ballots" description="Select a ballot." />}
|
|
{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)} />
|
|
{selected.state === "draft" && <IconButton label={`Edit ${selected.title}`} icon={<Pencil size={16} />} disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing(selected)} />}
|
|
{selected.state === "draft" && <Button variant="primary" disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setPendingTransition("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
|
|
{selected.state === "open" && <Button variant="primary" disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setPendingTransition("close")}><XCircle size={16} aria-hidden="true" />Close and tally</Button>}
|
|
{selected.state === "closed" && <Button variant="primary" disabled={busy || !canCertify} disabledReason={busy ? VOTING_I18N.busy : !canCertify ? VOTING_I18N.certifyReason : undefined} onClick={() => setPendingTransition("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</Button>}
|
|
{["closed", "certified"].includes(selected.state) && <Button disabled={busy || !canCertify} disabledReason={busy ? VOTING_I18N.busy : !canCertify ? VOTING_I18N.certifyReason : undefined} onClick={() => setReasonAction("challenge")}>Challenge</Button>}
|
|
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
|
|
</div>
|
|
</div>
|
|
<MetricGrid columns={4} spacing="block">
|
|
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
|
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
|
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
|
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
|
</MetricGrid>
|
|
{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} disabledReason={busy ? VOTING_I18N.busy : selections.length === 0 ? VOTING_I18N.incomplete : undefined} onClick={() => setConfirmingCast(true)}>Submit vote</Button>
|
|
</section>}
|
|
{selected.state === "open" && selected.assurance_profile === "recorded" && (!canCast || !eligible) && <ActionBlockerHint reason={{ summary: "Vote unavailable", details: !canCast ? VOTING_I18N.castReason : VOTING_I18N.ineligibleReason, requiredAction: VOTING_I18N.permissionAction, actor: VOTING_I18N.permissionActor, target: VOTING_I18N.permissionDestination }} labels={{ requiredAction: VOTING_I18N.requiredAction, actor: VOTING_I18N.actor, target: VOTING_I18N.destination }} documentation={VOTING_DOCUMENTATION} />}
|
|
<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>
|
|
<MetricGrid columns={4} spacing="block">
|
|
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
|
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
|
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
|
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
|
</MetricGrid>
|
|
<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(language)}</time></div>)}
|
|
</div>
|
|
</section>
|
|
</PageScrollViewport>}
|
|
</WorkspaceLayout>
|
|
{editing && <VotingBallotDialog
|
|
open
|
|
settings={settings}
|
|
ballot={editing === "new" ? null : editing}
|
|
currentAccountId={currentAccountId}
|
|
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.`);
|
|
return true;
|
|
} catch (failure) {
|
|
setError(message(failure, "The transition could not be recorded."));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
/>}
|
|
<ConfirmDialog
|
|
open={Boolean(pendingTransition)}
|
|
title="i18n:govoplan-voting.transition_title"
|
|
message={pendingTransition ? i18nMessage("i18n:govoplan-voting.transition_message", { action: translateText(humanize(pendingTransition)) }) : ""}
|
|
confirmLabel={pendingTransition ? humanize(pendingTransition) : "Confirm"}
|
|
busy={busy}
|
|
onCancel={() => setPendingTransition(null)}
|
|
onConfirm={() => {
|
|
if (!pendingTransition) return;
|
|
const action = pendingTransition;
|
|
setPendingTransition(null);
|
|
void run(action);
|
|
}}
|
|
/>
|
|
<ConfirmDialog
|
|
open={confirmingCast}
|
|
title="i18n:govoplan-voting.cast_title"
|
|
message="i18n:govoplan-voting.cast_message"
|
|
confirmLabel="Submit vote"
|
|
busy={busy}
|
|
onCancel={() => setConfirmingCast(false)}
|
|
onConfirm={() => {
|
|
setConfirmingCast(false);
|
|
void cast();
|
|
}}
|
|
/>
|
|
</WorkspaceFrame>
|
|
);
|
|
}
|
|
|
|
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<boolean> }) {
|
|
const [reason, setReason] = useState("");
|
|
const { requestDiscard } = useUnsavedChanges();
|
|
useUnsavedDraftGuard({ dirty: Boolean(reason), onSave: async () => Boolean(reason.trim()) && onConfirm(reason.trim()), onDiscard: () => setReason(""), title: "i18n:govoplan-voting.unsaved_title", message: "i18n:govoplan-voting.unsaved_message" });
|
|
const requestClose = () => { if (busy) return; if (reason) requestDiscard(onClose); else onClose(); };
|
|
return <Dialog open title={`${humanize(action)} ballot`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? VOTING_I18N.busy : undefined}>Cancel</Button><Button variant={action === "annul" ? "danger" : "primary"} disabled={busy || !reason.trim()} disabledReason={busy ? VOTING_I18N.busy : !reason.trim() ? VOTING_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim())}>Confirm</Button></>}>
|
|
<FormField label="Reason" documentation={VOTING_FIELD_DOCUMENTATION}><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;
|
|
}
|