Migrate Voting interface patterns
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Voting Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to Voting's
|
||||||
|
catalogue, ballot editor, casting, result, and governance-transition surfaces.
|
||||||
|
It preserves the strict distinction between recorded and provider-backed
|
||||||
|
assurance profiles.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/voting` catalogue | Governed work queue | Select, create, or manage ballot | Shared loading/empty/error/help and explicit management permission state |
|
||||||
|
| Ballot editor | Consequential definition editor | Save mutable draft | Guarded draft, contextual assurance/electorate help, validation reasons |
|
||||||
|
| Recorded cast panel | Guided decision | Record or replace authorized vote | Frozen eligibility, explicit selection, confirmation, privacy-bounded receipt |
|
||||||
|
| Result and assurance | Evidence/provenance | Inspect tally and hashes | Result visibility follows assurance profile and backend authorization |
|
||||||
|
| Open/close/certify/challenge/annul | Governed lifecycle | Freeze, tally, certify, challenge, or annul | Permission/lifecycle reasons, confirmations, required reasons, retained evidence |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Opening freezes options, electorate, weights, threshold, replacement rule,
|
||||||
|
assurance profile, and provider binding.
|
||||||
|
- Recorded ballots are reconstructable and are not secret. Provider-backed
|
||||||
|
privacy claims never exceed the installed provider contract.
|
||||||
|
- Closing stops casting and records a tally; certification adds evidence.
|
||||||
|
Challenge and annulment append reasoned events and erase nothing.
|
||||||
|
- Missing permission or electorate membership identifies the required action,
|
||||||
|
responsible actor, and destination.
|
||||||
|
- Optional Committee, Decision, Encryption, Forms, Trust, Policy, Reporting,
|
||||||
|
and Workflow integrations remain behind declared contracts.
|
||||||
|
|
||||||
|
The module uses shared controls, dialogs, confirmations, blockers, field help,
|
||||||
|
statuses, alerts, loading/empty states, disabled reasons, and unsaved-draft
|
||||||
|
guards. English and German catalogues cover the owned interaction vocabulary;
|
||||||
|
history timestamps follow the selected platform locale.
|
||||||
@@ -52,6 +52,16 @@ MANAGE_SCOPE = "voting:ballot:manage"
|
|||||||
CAST_SCOPE = "voting:ballot:cast"
|
CAST_SCOPE = "voting:ballot:cast"
|
||||||
CERTIFY_SCOPE = "voting:ballot:certify"
|
CERTIFY_SCOPE = "voting:ballot:certify"
|
||||||
ADMIN_SCOPE = "voting:ballot:admin"
|
ADMIN_SCOPE = "voting:ballot:admin"
|
||||||
|
OPTIONAL_DEPENDENCIES = (
|
||||||
|
"committee",
|
||||||
|
"decisions",
|
||||||
|
"encryption",
|
||||||
|
"forms",
|
||||||
|
"identity_trust",
|
||||||
|
"policy",
|
||||||
|
"reporting",
|
||||||
|
"workflow_engine",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -103,16 +113,7 @@ manifest = ModuleManifest(
|
|||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=(
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
"committee",
|
|
||||||
"decisions",
|
|
||||||
"encryption",
|
|
||||||
"forms",
|
|
||||||
"identity_trust",
|
|
||||||
"policy",
|
|
||||||
"reporting",
|
|
||||||
"workflow_engine",
|
|
||||||
),
|
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -308,6 +309,67 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"voting.navigation",
|
||||||
|
"voting.catalogue",
|
||||||
|
"voting.ballot",
|
||||||
|
"voting.state.permission-blocked",
|
||||||
|
"voting.state.ineligible",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Recorded ballots are reconstructable and must never be described as secret.",
|
||||||
|
"Provider-backed profiles expose only the aggregate, receipt, hash, and evidence allowed by the provider contract.",
|
||||||
|
"The bundled local confidential provider is server-readable and uncertified despite encrypting stored selections.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="voting.reference.fields-and-consequences",
|
||||||
|
title="Ballot fields, assurance, and lifecycle consequences",
|
||||||
|
summary="Frozen electorate, threshold, provider, receipt, tally, certification, challenge, and annulment semantics.",
|
||||||
|
body=(
|
||||||
|
"Opening freezes the exact options, method, electorate, weights, quorum, threshold, replacement rule, assurance profile, "
|
||||||
|
"and provider binding. Recorded ballots remain attributable and reconstructable. Confidential, secret, and externally "
|
||||||
|
"certified profiles are claims of their installed provider only; the local confidential provider is server-readable and "
|
||||||
|
"uncertified. Casting records or replaces a vote only when the frozen definition permits it and returns a receipt. Closing "
|
||||||
|
"prevents further casting and records the tally. Certification adds evidence without rewriting the result. Challenge and "
|
||||||
|
"annulment are separately reasoned, auditable transitions and never erase the frozen definition, receipts, or prior history."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Voting domain and assurance boundary",
|
||||||
|
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"voting.field.assurance-profile",
|
||||||
|
"voting.field.provider-reference",
|
||||||
|
"voting.field.electorate",
|
||||||
|
"voting.field.quorum-threshold",
|
||||||
|
"voting.action.cast",
|
||||||
|
"voting.action.open",
|
||||||
|
"voting.action.close",
|
||||||
|
"voting.action.certify",
|
||||||
|
"voting.action.challenge",
|
||||||
|
"voting.action.annul",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"open": "Freezes the ballot definition and electorate and permits authorized casting.",
|
||||||
|
"cast": "Records or replaces one authorized vote and returns a privacy-bounded receipt.",
|
||||||
|
"close": "Stops casting and records the aggregate tally.",
|
||||||
|
"certify": "Adds certification evidence without rewriting the frozen result.",
|
||||||
|
"challenge_or_annul": "Appends a reasoned governance transition while retaining prior evidence.",
|
||||||
|
},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_voting.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual({"/voting"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{"voting.navigation", "voting.catalogue", "voting.ballot"},
|
||||||
|
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_help_privacy_and_consequence_metadata_remain_published(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
guide = topics["voting.assurance"]
|
||||||
|
reference = topics["voting.reference.fields-and-consequences"]
|
||||||
|
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
||||||
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
|
self.assertIn("voting.field.assurance-profile", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("cast", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,10 +3,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings
|
type ApiSettings
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
type VotingBallot,
|
type VotingBallot,
|
||||||
type VotingBallotDraft
|
type VotingBallotDraft
|
||||||
} from "../../api/voting";
|
} from "../../api/voting";
|
||||||
|
import { VOTING_FIELD_DOCUMENTATION, VOTING_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
export default function VotingBallotDialog({
|
export default function VotingBallotDialog({
|
||||||
@@ -32,13 +36,17 @@ export default function VotingBallotDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (ballot: VotingBallot) => void;
|
onSaved: (ballot: VotingBallot) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<VotingBallotDraft>(() => initialDraft(currentAccountId, ballot));
|
const [baseline, setBaseline] = useState<VotingBallotDraft>(() => initialDraft(currentAccountId, ballot));
|
||||||
|
const [draft, setDraft] = useState<VotingBallotDraft>(baseline);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setDraft(initialDraft(currentAccountId, ballot));
|
const next = initialDraft(currentAccountId, ballot);
|
||||||
|
setDraft(next);
|
||||||
|
setBaseline(next);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setError("");
|
setError("");
|
||||||
}, [ballot, currentAccountId, open]);
|
}, [ballot, currentAccountId, open]);
|
||||||
@@ -60,9 +68,10 @@ export default function VotingBallotDialog({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
), [draft]);
|
), [draft]);
|
||||||
|
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(baseline), [baseline, draft]);
|
||||||
|
|
||||||
async function save() {
|
async function save(): Promise<boolean> {
|
||||||
if (!valid) return;
|
if (!valid) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
const payload: VotingBallotDraft = {
|
const payload: VotingBallotDraft = {
|
||||||
@@ -79,28 +88,45 @@ export default function VotingBallotDialog({
|
|||||||
? await updateVotingBallot(settings, ballot.id, ballot.revision, payload)
|
? await updateVotingBallot(settings, ballot.id, ballot.revision, payload)
|
||||||
: await createVotingBallot(settings, payload);
|
: await createVotingBallot(settings, payload);
|
||||||
onSaved(saved);
|
onSaved(saved);
|
||||||
|
return true;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "The ballot could not be saved.");
|
setError(reason instanceof Error ? reason.message : "The ballot could not be saved.");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: open && dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => setDraft(baseline),
|
||||||
|
title: "i18n:govoplan-voting.unsaved_title",
|
||||||
|
message: "i18n:govoplan-voting.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title={ballot ? `Edit ${ballot.title}` : "New ballot"}
|
title={ballot ? `Edit ${ballot.title}` : "New ballot"}
|
||||||
onClose={onClose}
|
onClose={requestClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
className="voting-ballot-dialog"
|
className="voting-ballot-dialog"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
<Button onClick={requestClose} disabled={busy} disabledReason={busy ? VOTING_I18N.busy : undefined}>Cancel</Button>
|
||||||
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}>{busy ? "Saving" : "Save ballot"}</Button>
|
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid} disabledReason={busy ? VOTING_I18N.busy : !valid ? VOTING_I18N.incomplete : undefined}>{busy ? "Saving" : "Save ballot"}</Button>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<div className="voting-ballot-editor">
|
<div className="voting-ballot-editor">
|
||||||
|
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
<div className="voting-editor-grid">
|
<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="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||||
@@ -112,7 +138,7 @@ export default function VotingBallotDialog({
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</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="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">
|
<FormField label="Assurance profile" documentation={VOTING_FIELD_DOCUMENTATION}>
|
||||||
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => {
|
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => {
|
||||||
const assurance_profile = event.target.value as VotingBallotDraft["assurance_profile"];
|
const assurance_profile = event.target.value as VotingBallotDraft["assurance_profile"];
|
||||||
setDraft({
|
setDraft({
|
||||||
@@ -139,8 +165,8 @@ export default function VotingBallotDialog({
|
|||||||
<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>
|
<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>
|
<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" && <>
|
{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 id" documentation={VOTING_FIELD_DOCUMENTATION}><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
|
||||||
<FormField label="Provider ballot reference">
|
<FormField label="Provider ballot reference" documentation={VOTING_FIELD_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
value={draft.provider_ballot_ref ?? ""}
|
value={draft.provider_ballot_ref ?? ""}
|
||||||
placeholder={draft.provider_id === "local_confidential" ? "Generated when opened" : undefined}
|
placeholder={draft.provider_id === "local_confidential" ? "Generated when opened" : undefined}
|
||||||
@@ -157,17 +183,17 @@ export default function VotingBallotDialog({
|
|||||||
<FormField label="Key"><input value={option.key} disabled={busy} onChange={(event) => patchOption(index, { key: event.target.value })} /></FormField>
|
<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="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>
|
<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) })} />
|
<IconButton label={`Remove ${option.label || "option"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.options.length <= 2} disabledReason={busy ? VOTING_I18N.busy : draft.options.length <= 2 ? VOTING_I18N.incomplete : undefined} onClick={() => setDraft({ ...draft, options: draft.options.filter((_, itemIndex) => itemIndex !== index) })} />
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorHeading title="Electorate" onAdd={() => setDraft({ ...draft, electorate: [...draft.electorate, { subject_id: "", label: "", weight: 1, provenance: {} }] })} disabled={busy} />
|
<EditorHeading title="Electorate" onAdd={() => setDraft({ ...draft, electorate: [...draft.electorate, { subject_id: "", label: "", weight: 1, provenance: {} }] })} disabled={busy} />
|
||||||
<div className="voting-editor-list">
|
<div className="voting-editor-list">
|
||||||
{draft.electorate.map((elector, index) => <div className="voting-elector-row" key={`${index}:${elector.subject_id}`}>
|
{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="Account id" documentation={VOTING_FIELD_DOCUMENTATION}><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="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>
|
<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) })} />
|
<IconButton label={`Remove ${elector.label || "elector"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.electorate.length <= 1} disabledReason={busy ? VOTING_I18N.busy : draft.electorate.length <= 1 ? VOTING_I18N.incomplete : undefined} onClick={() => setDraft({ ...draft, electorate: draft.electorate.filter((_, itemIndex) => itemIndex !== index) })} />
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
|
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -10,6 +13,10 @@ import {
|
|||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +30,11 @@ import {
|
|||||||
type VotingEvent
|
type VotingEvent
|
||||||
} from "../../api/voting";
|
} from "../../api/voting";
|
||||||
import VotingBallotDialog from "./VotingBallotDialog";
|
import VotingBallotDialog from "./VotingBallotDialog";
|
||||||
|
import { VOTING_DOCUMENTATION, VOTING_FIELD_DOCUMENTATION, VOTING_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const { language, translateText } = usePlatformLanguage();
|
||||||
const [items, setItems] = useState<VotingBallot[]>([]);
|
const [items, setItems] = useState<VotingBallot[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<VotingBallot | null>(null);
|
const [selected, setSelected] = useState<VotingBallot | null>(null);
|
||||||
@@ -37,6 +46,8 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
const [editing, setEditing] = useState<VotingBallot | "new" | null>(null);
|
const [editing, setEditing] = useState<VotingBallot | "new" | null>(null);
|
||||||
const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null);
|
const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null);
|
||||||
const [selections, setSelections] = useState<string[]>([]);
|
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 canManage = hasScope(auth, "voting:ballot:manage");
|
||||||
const canCast = hasScope(auth, "voting:ballot:cast");
|
const canCast = hasScope(auth, "voting:ballot:cast");
|
||||||
const canCertify = hasScope(auth, "voting:ballot:certify");
|
const canCertify = hasScope(auth, "voting:ballot:certify");
|
||||||
@@ -135,8 +146,9 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<div className="voting-shell">
|
<div className="voting-shell">
|
||||||
<aside className="voting-catalogue">
|
<aside className="voting-catalogue">
|
||||||
<div className="voting-toolbar">
|
<div className="voting-toolbar">
|
||||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void loadList()} />
|
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
||||||
{canManage && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
|
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
||||||
|
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
||||||
</div>
|
</div>
|
||||||
<PageScrollViewport className="voting-list-viewport">
|
<PageScrollViewport className="voting-list-viewport">
|
||||||
{loading && <LoadingIndicator label="Loading ballots" />}
|
{loading && <LoadingIndicator label="Loading ballots" />}
|
||||||
@@ -158,12 +170,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
||||||
<div className="voting-actions">
|
<div className="voting-actions">
|
||||||
<StatusBadge status={statusTone(selected.state)} label={humanize(selected.state)} />
|
<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)} />}
|
{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)} />}
|
||||||
{canManage && selected.state === "draft" && <Button variant="primary" disabled={busy} onClick={() => void run("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
|
{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>}
|
||||||
{canManage && selected.state === "open" && <Button variant="primary" disabled={busy} onClick={() => void run("close")}><XCircle size={16} aria-hidden="true" />Close and tally</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>}
|
||||||
{canCertify && selected.state === "closed" && <Button variant="primary" disabled={busy} onClick={() => void run("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</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>}
|
||||||
{canCertify && ["closed", "certified"].includes(selected.state) && <Button disabled={busy} onClick={() => setReasonAction("challenge")}>Challenge</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>}
|
||||||
{canAdmin && selected.state !== "annulled" && <Button variant="danger" disabled={busy} onClick={() => setReasonAction("annul")}>Annul</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>
|
||||||
</div>
|
</div>
|
||||||
<div className="voting-metrics">
|
<div className="voting-metrics">
|
||||||
@@ -188,8 +200,9 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
|
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
|
||||||
</label>)}
|
</label>)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" disabled={busy || selections.length === 0} onClick={() => void cast()}>Submit vote</Button>
|
<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>}
|
</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">
|
<section className="voting-section">
|
||||||
<h3>Options</h3>
|
<h3>Options</h3>
|
||||||
<div className="voting-option-results">
|
<div className="voting-option-results">
|
||||||
@@ -217,7 +230,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<section className="voting-section">
|
<section className="voting-section">
|
||||||
<h3>History</h3>
|
<h3>History</h3>
|
||||||
<div className="voting-history">
|
<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>)}
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageScrollViewport>}
|
</PageScrollViewport>}
|
||||||
@@ -246,13 +259,41 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
await reasonedVotingTransition(settings, selected, reasonAction, reason);
|
await reasonedVotingTransition(settings, selected, reasonAction, reason);
|
||||||
setReasonAction(null);
|
setReasonAction(null);
|
||||||
await reloadSelected(`${humanize(reasonAction)} recorded.`);
|
await reloadSelected(`${humanize(reasonAction)} recorded.`);
|
||||||
|
return true;
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(message(failure, "The transition could not be recorded."));
|
setError(message(failure, "The transition could not be recorded."));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,10 +306,13 @@ function Hash({ label, value }: { label: string; value?: string | null }) {
|
|||||||
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
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> }) {
|
function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise<boolean> }) {
|
||||||
const [reason, setReason] = useState("");
|
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></>}>
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
<FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>
|
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>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const VOTING_DOCUMENTATION = {
|
||||||
|
topicId: "voting.assurance",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const VOTING_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "voting.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const VOTING_I18N = {
|
||||||
|
loading: "i18n:govoplan-voting.loading_reason",
|
||||||
|
busy: "i18n:govoplan-voting.busy_reason",
|
||||||
|
manageReason: "i18n:govoplan-voting.manage_permission_reason",
|
||||||
|
castReason: "i18n:govoplan-voting.cast_permission_reason",
|
||||||
|
certifyReason: "i18n:govoplan-voting.certify_permission_reason",
|
||||||
|
adminReason: "i18n:govoplan-voting.admin_permission_reason",
|
||||||
|
lifecycleReason: "i18n:govoplan-voting.lifecycle_reason",
|
||||||
|
ineligibleReason: "i18n:govoplan-voting.ineligible_reason",
|
||||||
|
incomplete: "i18n:govoplan-voting.incomplete_reason",
|
||||||
|
requiredAction: "i18n:govoplan-voting.required_action",
|
||||||
|
actor: "i18n:govoplan-voting.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-voting.destination",
|
||||||
|
permissionAction: "i18n:govoplan-voting.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-voting.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-voting.permission_destination"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-voting.voting": "Voting",
|
||||||
|
"i18n:govoplan-voting.loading_reason": "Ballot data is still loading.",
|
||||||
|
"i18n:govoplan-voting.busy_reason": "Another ballot action is still running.",
|
||||||
|
"i18n:govoplan-voting.manage_permission_reason": "Your account may not create or manage ballots.",
|
||||||
|
"i18n:govoplan-voting.cast_permission_reason": "Your account may not cast a ballot.",
|
||||||
|
"i18n:govoplan-voting.certify_permission_reason": "Your account may not certify or challenge ballot results.",
|
||||||
|
"i18n:govoplan-voting.admin_permission_reason": "Only a Voting administrator may annul a ballot.",
|
||||||
|
"i18n:govoplan-voting.lifecycle_reason": "This action is unavailable in the ballot's current lifecycle state.",
|
||||||
|
"i18n:govoplan-voting.ineligible_reason": "The acting account is not part of the frozen electorate.",
|
||||||
|
"i18n:govoplan-voting.incomplete_reason": "Complete all required ballot, option, electorate, and reason fields first.",
|
||||||
|
"i18n:govoplan-voting.required_action": "Required action",
|
||||||
|
"i18n:govoplan-voting.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-voting.destination": "Destination",
|
||||||
|
"i18n:govoplan-voting.permission_action": "Ask for the corresponding Voting permission or electorate assignment.",
|
||||||
|
"i18n:govoplan-voting.permission_actor": "An Access administrator or ballot manager",
|
||||||
|
"i18n:govoplan-voting.permission_destination": "Access role assignments or the ballot electorate",
|
||||||
|
"i18n:govoplan-voting.unsaved_title": "Unsaved ballot",
|
||||||
|
"i18n:govoplan-voting.unsaved_message": "Save or discard the ballot draft or selection before leaving this surface.",
|
||||||
|
"i18n:govoplan-voting.transition_title": "Confirm ballot transition",
|
||||||
|
"i18n:govoplan-voting.transition_message": "{action} this ballot? The frozen definition, electorate, result, and evidence remain reconstructable.",
|
||||||
|
"i18n:govoplan-voting.cast_title": "Confirm vote",
|
||||||
|
"i18n:govoplan-voting.cast_message": "Submit the selected option or options? The receipt proves acceptance without exposing more ballot data than the assurance profile permits.",
|
||||||
|
"Voting": "Voting",
|
||||||
|
"Refresh ballots": "Refresh ballots",
|
||||||
|
"New ballot": "New ballot",
|
||||||
|
"Loading ballots": "Loading ballots",
|
||||||
|
"No ballots.": "No ballots.",
|
||||||
|
"Select a ballot.": "Select a ballot.",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Open": "Open",
|
||||||
|
"Close and tally": "Close and tally",
|
||||||
|
"Certify": "Certify",
|
||||||
|
"Challenge": "Challenge",
|
||||||
|
"Annul": "Annul",
|
||||||
|
"Electors": "Electors",
|
||||||
|
"Eligible weight": "Eligible weight",
|
||||||
|
"Quorum": "Quorum",
|
||||||
|
"Assurance": "Assurance",
|
||||||
|
"Cast vote": "Cast vote",
|
||||||
|
"Submit vote": "Submit vote",
|
||||||
|
"Options": "Options",
|
||||||
|
"Result": "Result",
|
||||||
|
"Votes": "Votes",
|
||||||
|
"Cast weight": "Cast weight",
|
||||||
|
"Threshold": "Threshold",
|
||||||
|
"Met": "Met",
|
||||||
|
"Not met": "Not met",
|
||||||
|
"Frozen assurance": "Frozen assurance",
|
||||||
|
"Definition": "Definition",
|
||||||
|
"Electorate": "Electorate",
|
||||||
|
"History": "History",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Confirm": "Confirm",
|
||||||
|
"Reason": "Reason",
|
||||||
|
"Saving": "Saving",
|
||||||
|
"Save ballot": "Save ballot",
|
||||||
|
"Title": "Title",
|
||||||
|
"Method": "Method",
|
||||||
|
"Description": "Description",
|
||||||
|
"Assurance profile": "Assurance profile",
|
||||||
|
"Quorum weight": "Quorum weight",
|
||||||
|
"Threshold numerator": "Threshold numerator",
|
||||||
|
"Threshold denominator": "Threshold denominator",
|
||||||
|
"Allow vote replacement": "Allow vote replacement",
|
||||||
|
"Provider id": "Provider id",
|
||||||
|
"Provider ballot reference": "Provider ballot reference",
|
||||||
|
"Account id": "Account id",
|
||||||
|
"Label": "Label",
|
||||||
|
"Weight": "Weight",
|
||||||
|
"Add": "Add",
|
||||||
|
"No ballot management permission": "No ballot management permission",
|
||||||
|
"Vote unavailable": "Vote unavailable"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-voting.voting": "Abstimmungen",
|
||||||
|
"i18n:govoplan-voting.loading_reason": "Abstimmungsdaten werden noch geladen.",
|
||||||
|
"i18n:govoplan-voting.busy_reason": "Eine andere Abstimmungsaktion läuft noch.",
|
||||||
|
"i18n:govoplan-voting.manage_permission_reason": "Ihr Konto darf Abstimmungen nicht erstellen oder verwalten.",
|
||||||
|
"i18n:govoplan-voting.cast_permission_reason": "Ihr Konto darf keine Stimme abgeben.",
|
||||||
|
"i18n:govoplan-voting.certify_permission_reason": "Ihr Konto darf Abstimmungsergebnisse nicht zertifizieren oder anfechten.",
|
||||||
|
"i18n:govoplan-voting.admin_permission_reason": "Nur eine Abstimmungsadministration darf eine Abstimmung annullieren.",
|
||||||
|
"i18n:govoplan-voting.lifecycle_reason": "Diese Aktion ist im aktuellen Lebenszyklus der Abstimmung nicht verfügbar.",
|
||||||
|
"i18n:govoplan-voting.ineligible_reason": "Das handelnde Konto gehört nicht zum eingefrorenen Kreis der Stimmberechtigten.",
|
||||||
|
"i18n:govoplan-voting.incomplete_reason": "Füllen Sie zuerst alle erforderlichen Abstimmungs-, Options-, Stimmberechtigten- und Begründungsfelder aus.",
|
||||||
|
"i18n:govoplan-voting.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-voting.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-voting.destination": "Ziel",
|
||||||
|
"i18n:govoplan-voting.permission_action": "Fordern Sie die entsprechende Abstimmungsberechtigung oder Aufnahme in den Kreis der Stimmberechtigten an.",
|
||||||
|
"i18n:govoplan-voting.permission_actor": "Eine Zugriffsadministration oder Abstimmungsleitung",
|
||||||
|
"i18n:govoplan-voting.permission_destination": "Zugriff und Rollenzuweisungen oder der Kreis der Stimmberechtigten",
|
||||||
|
"i18n:govoplan-voting.unsaved_title": "Ungespeicherte Abstimmung",
|
||||||
|
"i18n:govoplan-voting.unsaved_message": "Speichern oder verwerfen Sie den Abstimmungsentwurf oder die Auswahl, bevor Sie diese Oberfläche verlassen.",
|
||||||
|
"i18n:govoplan-voting.transition_title": "Abstimmungsübergang bestätigen",
|
||||||
|
"i18n:govoplan-voting.transition_message": "Diese Abstimmung {action}? Eingefrorene Definition, Stimmberechtigte, Ergebnis und Nachweis bleiben rekonstruierbar.",
|
||||||
|
"i18n:govoplan-voting.cast_title": "Stimmabgabe bestätigen",
|
||||||
|
"i18n:govoplan-voting.cast_message": "Die ausgewählte Option oder Optionen absenden? Der Beleg weist die Annahme nach, ohne mehr Abstimmungsdaten offenzulegen, als das Vertrauensprofil erlaubt.",
|
||||||
|
"Voting": "Abstimmungen",
|
||||||
|
"Refresh ballots": "Abstimmungen aktualisieren",
|
||||||
|
"New ballot": "Neue Abstimmung",
|
||||||
|
"Loading ballots": "Abstimmungen werden geladen",
|
||||||
|
"No ballots.": "Keine Abstimmungen.",
|
||||||
|
"Select a ballot.": "Wählen Sie eine Abstimmung.",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Open": "Öffnen",
|
||||||
|
"Close and tally": "Schließen und auszählen",
|
||||||
|
"Certify": "Zertifizieren",
|
||||||
|
"Challenge": "Anfechten",
|
||||||
|
"Annul": "Annullieren",
|
||||||
|
"Electors": "Stimmberechtigte",
|
||||||
|
"Eligible weight": "Stimmberechtigtes Gewicht",
|
||||||
|
"Quorum": "Quorum",
|
||||||
|
"Assurance": "Vertrauensniveau",
|
||||||
|
"Cast vote": "Stimme abgeben",
|
||||||
|
"Submit vote": "Stimme absenden",
|
||||||
|
"Options": "Optionen",
|
||||||
|
"Result": "Ergebnis",
|
||||||
|
"Votes": "Stimmen",
|
||||||
|
"Cast weight": "Abgegebenes Gewicht",
|
||||||
|
"Threshold": "Schwelle",
|
||||||
|
"Met": "Erfüllt",
|
||||||
|
"Not met": "Nicht erfüllt",
|
||||||
|
"Frozen assurance": "Eingefrorener Nachweis",
|
||||||
|
"Definition": "Definition",
|
||||||
|
"Electorate": "Stimmberechtigte",
|
||||||
|
"History": "Verlauf",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Confirm": "Bestätigen",
|
||||||
|
"Reason": "Grund",
|
||||||
|
"Saving": "Speichern",
|
||||||
|
"Save ballot": "Abstimmung speichern",
|
||||||
|
"Title": "Titel",
|
||||||
|
"Method": "Verfahren",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Assurance profile": "Vertrauensprofil",
|
||||||
|
"Quorum weight": "Quorumgewicht",
|
||||||
|
"Threshold numerator": "Schwellenzähler",
|
||||||
|
"Threshold denominator": "Schwellennenner",
|
||||||
|
"Allow vote replacement": "Ersetzung der Stimme erlauben",
|
||||||
|
"Provider id": "Anbieter-ID",
|
||||||
|
"Provider ballot reference": "Abstimmungsreferenz des Anbieters",
|
||||||
|
"Account id": "Konto-ID",
|
||||||
|
"Label": "Bezeichnung",
|
||||||
|
"Weight": "Gewicht",
|
||||||
|
"Add": "Hinzufügen",
|
||||||
|
"No ballot management permission": "Keine Berechtigung zur Abstimmungsverwaltung",
|
||||||
|
"Vote unavailable": "Stimmabgabe nicht verfügbar"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/voting.css";
|
import "./styles/voting.css";
|
||||||
|
|
||||||
|
|
||||||
@@ -7,10 +8,11 @@ const VotingPage = lazy(() => import("./features/voting/VotingPage"));
|
|||||||
|
|
||||||
export const votingModule: PlatformWebModule = {
|
export const votingModule: PlatformWebModule = {
|
||||||
id: "voting",
|
id: "voting",
|
||||||
label: "Voting",
|
label: "i18n:govoplan-voting.voting",
|
||||||
version: "0.1.14",
|
version: "0.1.14",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["committee", "decisions", "encryption", "forms", "identity_trust", "policy", "reporting", "workflow_engine"],
|
optionalDependencies: ["committee", "decisions", "encryption", "forms", "identity_trust", "policy", "reporting", "workflow_engine"],
|
||||||
|
translations: generatedTranslations,
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: "/voting",
|
path: "/voting",
|
||||||
@@ -23,7 +25,7 @@ export const votingModule: PlatformWebModule = {
|
|||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/voting",
|
to: "/voting",
|
||||||
label: "Voting",
|
label: "i18n:govoplan-voting.voting",
|
||||||
iconName: "vote",
|
iconName: "vote",
|
||||||
anyOf: ["voting:ballot:read"],
|
anyOf: ["voting:ballot:read"],
|
||||||
order: 39,
|
order: 39,
|
||||||
|
|||||||
@@ -85,6 +85,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voting-workspace > .action-blocker-hint {
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.voting-detail-viewport > div {
|
.voting-detail-viewport > div {
|
||||||
padding: 16px 20px 28px;
|
padding: 16px 20px 28px;
|
||||||
}
|
}
|
||||||
@@ -214,6 +218,11 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voting-editor-help {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.voting-editor-grid {
|
.voting-editor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user