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([]); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [editing, setEditing] = useState(null); const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null); const [selections, setSelections] = useState([]); 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 ( void loadList().catch((reason) => setError(message(reason, "Ballots could not be loaded."))), loading: loading || busy, label: "Refresh ballots" }} createAction={} title="Ballots" titleLevel={1} titleHelp={} /> {loading && } {!loading && items.length === 0 && } {items.map((item) => setSelectedId(item.id)}> )} } > {error && {error}} {notice && {notice}} {!selected && !loading && } {selected &&

{selected.title}

Revision {selected.revision}
{selected.state === "draft" && } disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing(selected)} />} {selected.state === "draft" && } {selected.state === "open" && } {selected.state === "closed" && } {["closed", "certified"].includes(selected.state) && } {selected.state !== "annulled" && }
total + item.weight, 0)} /> {selected.description &&

{selected.description}

} {selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible &&

Cast vote

{selected.options.map((option) => )}
} {selected.state === "open" && selected.assurance_profile === "recorded" && (!canCast || !eligible) && }

Options

{selected.options.map((option) =>
{option.label}{option.key} {selected.result && {selected.result.counts[option.key] ?? 0} votes / {selected.result.weighted_counts[option.key] ?? 0} weight}
)}
{selected.result &&

Result

}

Frozen assurance

History

{history.map((item) =>
{item.sequence}{humanize(item.event_type)}
)}
}
{editing && setEditing(null)} onSaved={(ballot) => { setEditing(null); setSelectedId(ballot.id); void loadList(undefined, ballot.id); }} />} {selected && reasonAction && 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); } }} />} setPendingTransition(null)} onConfirm={() => { if (!pendingTransition) return; const action = pendingTransition; setPendingTransition(null); void run(action); }} /> setConfirmingCast(false)} onConfirm={() => { setConfirmingCast(false); void cast(); }} />
); } function Hash({ label, value }: { label: string; value?: string | null }) { return
{label}{value || "Not frozen"}
; } function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise }) { 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 }>