Migrate Committee interface patterns
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
# Committee Interface Pattern Migration
|
||||||
|
|
||||||
|
This document records the bounded migration of Committee-owned WebUI surfaces
|
||||||
|
to the GovOPlaN interface pattern language. Core owns shared controls, help,
|
||||||
|
blocking explanations, draft guards, confirmations, and host shells. Committee
|
||||||
|
owns bodies, meetings, agenda context, vote context, minutes, and the bounded
|
||||||
|
projection of their institutional evidence.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/committee` toolbar and body list | Searchable workspace directory | Change query projection or create body | Shared search/button/help controls, explicit loading/error/permission states |
|
||||||
|
| Body and meeting selectors | Master-detail selection | Change active context | Stable keyboard buttons, selected/hover state, bounded independent scrolling |
|
||||||
|
| Meeting detail | Governed record workspace | Create or revise agenda, vote, and minute records | Shared sections, statuses, action explanations, locale-aware dates |
|
||||||
|
| Committee record dialog | Consequential record editor | Save immutable revision or change lifecycle | Contextual field help, guarded draft, required change reason, lifecycle confirmation |
|
||||||
|
| Ballot finalization dialog | Governed provider action | Import aggregate result and evidence | Permission explanation, guarded draft, explicit confirmation, no individual ballots |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Every save creates a new Committee revision and retains the change reason.
|
||||||
|
- Terminal lifecycle states are immutable; disabled edit actions explain that
|
||||||
|
constraint rather than disappearing.
|
||||||
|
- Cancelling, withdrawing, or retiring stops future work but never removes
|
||||||
|
existing revisions, minutes, decisions, or evidence.
|
||||||
|
- Provider and Voting integrations remain optional capability boundaries.
|
||||||
|
Committee stores only the aggregate result and assurance evidence.
|
||||||
|
- Missing write or ballot-finalization permission identifies the required
|
||||||
|
action, responsible administrator, and Access destination.
|
||||||
|
- Unsaved editors intercept route, browser, backdrop, and explicit close
|
||||||
|
attempts through the shared draft guard.
|
||||||
|
|
||||||
|
## State And Accessibility Evidence
|
||||||
|
|
||||||
|
The module uses shared buttons, icon buttons, dialogs, confirmations, alerts,
|
||||||
|
status badges, loading indicators, action blockers, field help, and draft
|
||||||
|
guards. Native buttons preserve keyboard order and shared dialogs retain focus.
|
||||||
|
The existing three-region desktop layout and two-region compact layout keep
|
||||||
|
lists and details independently scrollable. English and German catalogues cover
|
||||||
|
module metadata and owned UI copy, while dates follow the selected platform
|
||||||
|
locale. Manifest topics expose stable route, field, blocker, privacy, and
|
||||||
|
consequence references without importing optional sibling modules.
|
||||||
@@ -237,6 +237,17 @@ DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"committee.navigation",
|
||||||
|
"committee.workspace",
|
||||||
|
"committee.state.read-only",
|
||||||
|
"committee.state.permission-blocked",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Committee lists expose only records authorized by the active tenant and permission context.",
|
||||||
|
"Provider-backed voting stores aggregate result evidence, not individual confidential ballots.",
|
||||||
|
"Protected decision reasoning requires the dedicated protected-read permission.",
|
||||||
|
],
|
||||||
"domain_objects": [
|
"domain_objects": [
|
||||||
"committee bodies",
|
"committee bodies",
|
||||||
"meeting agendas",
|
"meeting agendas",
|
||||||
@@ -252,6 +263,53 @@ DOCUMENTATION = (
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
||||||
|
title="Committee fields and consequences",
|
||||||
|
summary="Field provenance, lifecycle restrictions, optional provider references, and evidence consequences for Committee records.",
|
||||||
|
body=(
|
||||||
|
"Body organization-unit references determine institutional context but do not grant access by themselves. "
|
||||||
|
"Meeting times establish agenda context. Subject, Decision, Approval, record, and ballot identifiers are stable "
|
||||||
|
"cross-module references and remain optional only where the current lifecycle allows it. State transitions create "
|
||||||
|
"new immutable revisions; terminal records cannot be edited in place. Closing a vote or accepting minutes requires "
|
||||||
|
"the corresponding governed evidence. Provider ballot finalization imports only an aggregate result and assurance "
|
||||||
|
"evidence. Change reasons are retained with every revision. Cancelling, withdrawing, or retiring a record stops future "
|
||||||
|
"work but does not erase existing revisions, decisions, minutes, or audit evidence."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
order=110,
|
||||||
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Committee domain boundary",
|
||||||
|
href="govoplan-committee/docs/COMMITTEE_DOMAIN_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"committee.field.state",
|
||||||
|
"committee.field.organization-unit-reference",
|
||||||
|
"committee.field.subject-reference",
|
||||||
|
"committee.field.decision-reference",
|
||||||
|
"committee.field.approval-reference",
|
||||||
|
"committee.field.evidence-reference",
|
||||||
|
"committee.field.ballot-provider-reference",
|
||||||
|
"committee.field.change-reason",
|
||||||
|
"committee.action.change-state",
|
||||||
|
"committee.action.finalize-ballot",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"save_revision": "Creates a new immutable Committee record revision with its change reason.",
|
||||||
|
"change_lifecycle": "Changes which future actions remain possible; terminal states are immutable.",
|
||||||
|
"finalize_ballot": "Imports a governed aggregate result and evidence without retaining individual ballots.",
|
||||||
|
"cancel_or_retire": "Stops future use while retaining institutional and audit evidence.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_committee.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class CommitteeInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual(
|
||||||
|
{"/committee"},
|
||||||
|
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"committee.navigation", "committee.workspace"},
|
||||||
|
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_topics_publish_help_privacy_and_consequence_metadata(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertIn("committee.module-boundary", topics)
|
||||||
|
self.assertIn("committee.reference.fields-and-consequences", topics)
|
||||||
|
|
||||||
|
guide = topics["committee.module-boundary"]
|
||||||
|
self.assertIn("committee.workspace", guide.metadata["help_contexts"])
|
||||||
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
|
|
||||||
|
reference = topics["committee.reference.fields-and-consequences"]
|
||||||
|
self.assertIn("committee.field.state", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("committee.action.finalize-ballot", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("change_lifecycle", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("finalize_ballot", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
|
i18nMessage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings
|
type ApiSettings
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +16,10 @@ import {
|
|||||||
finalizeVotingBallot,
|
finalizeVotingBallot,
|
||||||
type CommitteeRecord
|
type CommitteeRecord
|
||||||
} from "../../api/committee";
|
} from "../../api/committee";
|
||||||
|
import {
|
||||||
|
COMMITTEE_FIELD_DOCUMENTATION,
|
||||||
|
COMMITTEE_INTERFACE_I18N
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
export default function CommitteeBallotDialog({
|
export default function CommitteeBallotDialog({
|
||||||
@@ -31,6 +40,8 @@ export default function CommitteeBallotDialog({
|
|||||||
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
|
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -38,9 +49,12 @@ export default function CommitteeBallotDialog({
|
|||||||
setApprovalId("");
|
setApprovalId("");
|
||||||
setChangeReason("Imported verified ballot aggregate.");
|
setChangeReason("Imported verified ballot aggregate.");
|
||||||
setError("");
|
setError("");
|
||||||
|
setConfirming(false);
|
||||||
}, [open, record.object_id]);
|
}, [open, record.object_id]);
|
||||||
|
|
||||||
async function finalize() {
|
const dirty = Boolean(providerBallotRef || approvalId || changeReason !== "Imported verified ballot aggregate.");
|
||||||
|
|
||||||
|
async function finalize(closeAfter = true): Promise<boolean> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -52,31 +66,54 @@ export default function CommitteeBallotDialog({
|
|||||||
changeReason
|
changeReason
|
||||||
});
|
});
|
||||||
onSaved(saved);
|
onSaved(saved);
|
||||||
onClose();
|
if (closeAfter) onClose();
|
||||||
|
return true;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
|
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: open && dirty,
|
||||||
|
onSave: () => finalize(false),
|
||||||
|
onDiscard: () => {
|
||||||
|
setProviderBallotRef("");
|
||||||
|
setApprovalId("");
|
||||||
|
setChangeReason("Imported verified ballot aggregate.");
|
||||||
|
},
|
||||||
|
title: "i18n:govoplan-committee.unsaved_title",
|
||||||
|
message: "i18n:govoplan-committee.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
const providerId = String(record.attributes.provider_id ?? "");
|
const providerId = String(record.attributes.provider_id ?? "");
|
||||||
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||||
|
const incomplete = (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim();
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
|
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
|
||||||
onClose={onClose}
|
onClose={requestClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
className="committee-ballot-dialog"
|
className="committee-ballot-dialog"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
<Button disabled={busy} disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : undefined} onClick={requestClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
|
disabled={busy || incomplete}
|
||||||
onClick={() => void finalize()}
|
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
>
|
>
|
||||||
{busy ? "Importing" : "Finalize"}
|
{busy ? "Importing" : "Finalize"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -84,22 +121,36 @@ export default function CommitteeBallotDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="committee-record-form">
|
<div className="committee-record-form">
|
||||||
|
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
|
||||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
<p className="committee-dialog-note">
|
<p className="committee-dialog-note">
|
||||||
{votingBallotId
|
{votingBallotId
|
||||||
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
|
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
|
||||||
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
|
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
|
||||||
</p>
|
</p>
|
||||||
{!votingBallotId ? <FormField label="Provider ballot reference">
|
{!votingBallotId ? <FormField label="Provider ballot reference" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
||||||
</FormField> : null}
|
</FormField> : null}
|
||||||
<FormField label="Approval ID">
|
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Change reason">
|
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
|
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="i18n:govoplan-committee.finalize_title"
|
||||||
|
message={i18nMessage("i18n:govoplan-committee.confirm_ballot_finalization", { title: record.title })}
|
||||||
|
confirmLabel="Finalize"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
void finalize();
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +31,11 @@ import {
|
|||||||
} from "../../api/committee";
|
} from "../../api/committee";
|
||||||
import CommitteeBallotDialog from "./CommitteeBallotDialog";
|
import CommitteeBallotDialog from "./CommitteeBallotDialog";
|
||||||
import CommitteeRecordDialog from "./CommitteeRecordDialog";
|
import CommitteeRecordDialog from "./CommitteeRecordDialog";
|
||||||
|
import {
|
||||||
|
COMMITTEE_DOCUMENTATION,
|
||||||
|
COMMITTEE_INTERFACE_I18N,
|
||||||
|
committeeDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
import { canReviseCommitteeRecord } from "./lifecycle";
|
import { canReviseCommitteeRecord } from "./lifecycle";
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +46,7 @@ type EditorTarget = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
|
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const { language, translateText } = usePlatformLanguage();
|
||||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||||
const canWrite = hasScope(auth, "committee:workspace:write");
|
const canWrite = hasScope(auth, "committee:workspace:write");
|
||||||
const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize");
|
const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize");
|
||||||
@@ -165,9 +175,9 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
|
|
||||||
const meetingTime = useMemo(
|
const meetingTime = useMemo(
|
||||||
() => selectedMeeting
|
() => selectedMeeting
|
||||||
? `${formatDateTime(selectedMeeting.attributes.starts_at)} - ${formatTime(selectedMeeting.attributes.ends_at)}`
|
? `${formatDateTime(selectedMeeting.attributes.starts_at, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}`
|
||||||
: "",
|
: "",
|
||||||
[selectedMeeting]
|
[language, selectedMeeting]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,20 +188,42 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
<Search size={16} aria-hidden="true" />
|
<Search size={16} aria-hidden="true" />
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
|
||||||
</form>
|
</form>
|
||||||
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
|
<Button variant="ghost" onClick={() => void refresh()} disabled={loading} disabledReason={loading ? COMMITTEE_INTERFACE_I18N.loading : undefined}>
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{canWrite ? (
|
<Button
|
||||||
<Button variant="primary" onClick={() => setEditor({ kind: "body" })}>
|
variant="primary"
|
||||||
<Plus size={16} aria-hidden="true" />
|
disabled={!canWrite || loading}
|
||||||
New body
|
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
|
||||||
</Button>
|
onClick={() => setEditor({ kind: "body" })}
|
||||||
) : null}
|
>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
New body
|
||||||
|
</Button>
|
||||||
|
<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
||||||
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
||||||
|
{!canWrite ? (
|
||||||
|
<ActionBlockerHint
|
||||||
|
tone="info"
|
||||||
|
reason={{
|
||||||
|
summary: "No Committee management permission",
|
||||||
|
details: COMMITTEE_INTERFACE_I18N.writeReason,
|
||||||
|
requiredAction: COMMITTEE_INTERFACE_I18N.permissionAction,
|
||||||
|
actor: COMMITTEE_INTERFACE_I18N.permissionActor,
|
||||||
|
target: COMMITTEE_INTERFACE_I18N.permissionDestination
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: COMMITTEE_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: COMMITTEE_INTERFACE_I18N.actor,
|
||||||
|
target: COMMITTEE_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={COMMITTEE_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="committee-workspace">
|
<div className="committee-workspace">
|
||||||
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
|
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
|
||||||
@@ -199,10 +231,21 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
<PageScrollViewport className="committee-panel-scroll">
|
<PageScrollViewport className="committee-panel-scroll">
|
||||||
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
|
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
{selectedBody && canWrite ? (
|
{selectedBody ? (
|
||||||
<div className="committee-panel-actions">
|
<div className="committee-panel-actions">
|
||||||
{canReviseCommitteeRecord(selectedBody) ? <IconButton label="Edit body" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null}
|
<IconButton
|
||||||
<Button variant="primary" onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}>
|
label="Edit body"
|
||||||
|
icon={<Pencil size={16} />}
|
||||||
|
disabled={!canWrite || !canReviseCommitteeRecord(selectedBody)}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedBody) })}
|
||||||
|
onClick={() => setEditor({ kind: "body", record: selectedBody })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canWrite}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||||
|
onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}
|
||||||
|
>
|
||||||
<CalendarPlus size={16} aria-hidden="true" />
|
<CalendarPlus size={16} aria-hidden="true" />
|
||||||
New meeting
|
New meeting
|
||||||
</Button>
|
</Button>
|
||||||
@@ -213,7 +256,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
|
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
|
||||||
<PanelHeading title="Meetings" count={meetings.length} />
|
<PanelHeading title="Meetings" count={meetings.length} />
|
||||||
<PageScrollViewport className="committee-panel-scroll">
|
<PageScrollViewport className="committee-panel-scroll">
|
||||||
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={meetingSecondary} />
|
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={(record) => meetingSecondary(record, language, translateText)} />
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -227,27 +270,44 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
<span>{meetingTime}</span>
|
<span>{meetingTime}</span>
|
||||||
<h1>{selectedMeeting.title}</h1>
|
<h1>{selectedMeeting.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={statusTone(selectedMeeting.state)} label={humanize(selectedMeeting.state)} />
|
<StatusBadge status={statusTone(selectedMeeting.state)} label={stateLabel(selectedMeeting.state)} />
|
||||||
{canWrite && canReviseCommitteeRecord(selectedMeeting) ? <IconButton label="Edit meeting" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null}
|
<IconButton
|
||||||
|
label="Edit meeting"
|
||||||
|
icon={<Pencil size={16} />}
|
||||||
|
disabled={!canWrite || !canReviseCommitteeRecord(selectedMeeting)}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedMeeting) })}
|
||||||
|
onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PageScrollViewport className="committee-detail-scroll">
|
<PageScrollViewport className="committee-detail-scroll">
|
||||||
<WorkspaceSection
|
<WorkspaceSection
|
||||||
title="Agenda"
|
title="Agenda"
|
||||||
action={canWrite ? (
|
action={(
|
||||||
<Button variant="ghost" onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||||
|
onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}
|
||||||
|
>
|
||||||
<ListPlus size={16} aria-hidden="true" />
|
<ListPlus size={16} aria-hidden="true" />
|
||||||
Add item
|
Add item
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
>
|
>
|
||||||
<div className="committee-agenda-list">
|
<div className="committee-agenda-list">
|
||||||
{agendaItems.map((item) => (
|
{agendaItems.map((item) => (
|
||||||
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
|
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
|
||||||
<button type="button" onClick={() => setAgendaId(item.object_id)}>
|
<button type="button" onClick={() => setAgendaId(item.object_id)}>
|
||||||
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
|
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
|
||||||
<span><strong>{item.title}</strong><small>{humanize(item.state)}</small></span>
|
<span><strong>{item.title}</strong><small>{stateLabel(item.state)}</small></span>
|
||||||
</button>
|
</button>
|
||||||
{canWrite && canReviseCommitteeRecord(item) ? <IconButton label="Edit agenda item" icon={<Pencil size={15} />} onClick={() => setEditor({ kind: "agenda_item", record: item })} /> : null}
|
<IconButton
|
||||||
|
label="Edit agenda item"
|
||||||
|
icon={<Pencil size={15} />}
|
||||||
|
disabled={!canWrite || !canReviseCommitteeRecord(item)}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(item) })}
|
||||||
|
onClick={() => setEditor({ kind: "agenda_item", record: item })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
|
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
|
||||||
@@ -256,23 +316,30 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
|
|
||||||
{selectedAgenda ? (
|
{selectedAgenda ? (
|
||||||
<WorkspaceSection
|
<WorkspaceSection
|
||||||
title={`Votes - ${selectedAgenda.title}`}
|
title={i18nMessage("i18n:govoplan-committee.votes_for", { title: selectedAgenda.title })}
|
||||||
action={canWrite ? (
|
action={(
|
||||||
<Button variant="ghost" onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||||
|
onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}
|
||||||
|
>
|
||||||
<Vote size={16} aria-hidden="true" />
|
<Vote size={16} aria-hidden="true" />
|
||||||
Add vote
|
Add vote
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
>
|
>
|
||||||
<RecordRows
|
<RecordRows
|
||||||
records={votes}
|
records={votes}
|
||||||
canEdit={(record) => canWrite && canReviseCommitteeRecord(record)}
|
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
||||||
onEdit={(record) => setEditor({ kind: "vote", record })}
|
onEdit={(record) => setEditor({ kind: "vote", record })}
|
||||||
detail={voteSummary}
|
detail={(record) => voteSummary(record, translateText)}
|
||||||
secondaryAction={(record) => canFinalizeBallot && isProviderBallotReady(record) ? (
|
secondaryAction={(record) => isProviderBallotReady(record) ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={`Finalize ${record.title} from ballot provider`}
|
label={i18nMessage("i18n:govoplan-committee.finalize_provider_record", { title: record.title })}
|
||||||
icon={<ShieldCheck size={15} />}
|
icon={<ShieldCheck size={15} />}
|
||||||
|
disabled={!canFinalizeBallot}
|
||||||
|
disabledReason={!canFinalizeBallot ? COMMITTEE_INTERFACE_I18N.finalizeReason : undefined}
|
||||||
onClick={() => setBallotRecord(record)}
|
onClick={() => setBallotRecord(record)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -282,14 +349,24 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
|||||||
|
|
||||||
<WorkspaceSection
|
<WorkspaceSection
|
||||||
title="Minutes"
|
title="Minutes"
|
||||||
action={canWrite ? (
|
action={(
|
||||||
<Button variant="ghost" onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
|
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||||
|
onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}
|
||||||
|
>
|
||||||
<FilePlus2 size={16} aria-hidden="true" />
|
<FilePlus2 size={16} aria-hidden="true" />
|
||||||
Add minutes
|
Add minutes
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
>
|
>
|
||||||
<RecordRows records={minutes} canEdit={(record) => canWrite && canReviseCommitteeRecord(record)} onEdit={(record) => setEditor({ kind: "minute", record })} detail={(record) => `Record ${String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-")}`} />
|
<RecordRows
|
||||||
|
records={minutes}
|
||||||
|
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
||||||
|
onEdit={(record) => setEditor({ kind: "minute", record })}
|
||||||
|
detail={(record) => i18nMessage("i18n:govoplan-committee.record_reference", { id: String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-") })}
|
||||||
|
/>
|
||||||
</WorkspaceSection>
|
</WorkspaceSection>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</>
|
</>
|
||||||
@@ -337,7 +414,7 @@ function RecordList({ records, selectedId, onSelect, secondary }: {
|
|||||||
return <div className="committee-record-list">{records.map((record) => (
|
return <div className="committee-record-list">{records.map((record) => (
|
||||||
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
|
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
|
||||||
<strong>{record.title}</strong>
|
<strong>{record.title}</strong>
|
||||||
<span>{secondary?.(record) ?? humanize(record.state)}</span>
|
<span>{secondary?.(record) ?? stateLabel(record.state)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}</div>;
|
))}</div>;
|
||||||
}
|
}
|
||||||
@@ -346,9 +423,9 @@ function WorkspaceSection({ title, action, children }: { title: string; action?:
|
|||||||
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
|
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
|
function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryAction }: {
|
||||||
records: CommitteeRecord[];
|
records: CommitteeRecord[];
|
||||||
canEdit: (record: CommitteeRecord) => boolean;
|
editDisabledReason: (record: CommitteeRecord) => string | undefined;
|
||||||
onEdit: (record: CommitteeRecord) => void;
|
onEdit: (record: CommitteeRecord) => void;
|
||||||
detail: (record: CommitteeRecord) => string;
|
detail: (record: CommitteeRecord) => string;
|
||||||
secondaryAction?: (record: CommitteeRecord) => ReactNode;
|
secondaryAction?: (record: CommitteeRecord) => ReactNode;
|
||||||
@@ -357,23 +434,33 @@ function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
|
|||||||
return <div className="committee-record-rows">{records.map((record) => (
|
return <div className="committee-record-rows">{records.map((record) => (
|
||||||
<div key={record.object_id}>
|
<div key={record.object_id}>
|
||||||
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
|
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
|
||||||
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
|
<StatusBadge status={statusTone(record.state)} label={stateLabel(record.state)} />
|
||||||
<span className="committee-row-actions">
|
<span className="committee-row-actions">
|
||||||
{secondaryAction?.(record)}
|
{secondaryAction?.(record)}
|
||||||
{canEdit(record) ? <IconButton label={`Edit ${record.title}`} icon={<Pencil size={15} />} onClick={() => onEdit(record)} /> : null}
|
<IconButton
|
||||||
|
label={i18nMessage("i18n:govoplan-committee.edit_record", { title: record.title })}
|
||||||
|
icon={<Pencil size={15} />}
|
||||||
|
disabled={Boolean(editDisabledReason(record))}
|
||||||
|
disabledReason={editDisabledReason(record)}
|
||||||
|
onClick={() => onEdit(record)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}</div>;
|
))}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function meetingSecondary(record: CommitteeRecord): string {
|
function meetingSecondary(record: CommitteeRecord, locale: string, translateText: (value: string) => string): string {
|
||||||
return `${formatDateTime(record.attributes.starts_at)} - ${humanize(record.state)}`;
|
return `${formatDateTime(record.attributes.starts_at, locale)} - ${translateText(stateLabel(record.state))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function voteSummary(record: CommitteeRecord): string {
|
function voteSummary(record: CommitteeRecord, translateText: (value: string) => string): string {
|
||||||
const cast = Number(record.attributes.cast_count ?? 0);
|
const cast = Number(record.attributes.cast_count ?? 0);
|
||||||
const eligible = Number(record.attributes.eligible_count ?? 0);
|
const eligible = Number(record.attributes.eligible_count ?? 0);
|
||||||
return `${humanize(String(record.attributes.method ?? "recorded"))}, ${cast}/${eligible} cast`;
|
return i18nMessage("i18n:govoplan-committee.vote_summary", {
|
||||||
|
method: translateText(domainLabel(String(record.attributes.method ?? "recorded"))),
|
||||||
|
cast,
|
||||||
|
eligible
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
||||||
@@ -389,16 +476,20 @@ function statusTone(state: string): "active" | "inactive" | "warning" {
|
|||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: unknown): string {
|
function formatDateTime(value: unknown, locale?: string): string {
|
||||||
if (!value) return "Date not set";
|
if (!value) return "Date not set";
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
|
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: unknown): string {
|
function formatTime(value: unknown, locale?: string): string {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(new Date(String(value)));
|
return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(new Date(String(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanize(value: string): string {
|
function stateLabel(value: string): string {
|
||||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
return `i18n:govoplan-committee.state_${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainLabel(value: string): string {
|
||||||
|
return `i18n:govoplan-committee.domain_${value}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings
|
type ApiSettings
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +18,11 @@ import {
|
|||||||
type CommitteeRecord
|
type CommitteeRecord
|
||||||
} from "../../api/committee";
|
} from "../../api/committee";
|
||||||
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
|
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
|
||||||
|
import {
|
||||||
|
COMMITTEE_FIELD_DOCUMENTATION,
|
||||||
|
COMMITTEE_INTERFACE_I18N,
|
||||||
|
committeeDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -56,9 +67,14 @@ export default function CommitteeRecordDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (record: CommitteeRecord) => void;
|
onSaved: (record: CommitteeRecord) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<Draft>(() => draftFromRecord(kind, record));
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const initialDraft = useMemo(() => draftFromRecord(kind, record), [kind, record]);
|
||||||
|
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||||
|
const [baseline, setBaseline] = useState<Draft>(initialDraft);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [confirmLifecycleChange, setConfirmLifecycleChange] = useState(false);
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
const choices = useMemo(
|
const choices = useMemo(
|
||||||
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
|
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
|
||||||
[draft.choices]
|
[draft.choices]
|
||||||
@@ -70,11 +86,16 @@ export default function CommitteeRecordDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setDraft(draftFromRecord(kind, record));
|
setDraft(initialDraft);
|
||||||
|
setBaseline(initialDraft);
|
||||||
setError("");
|
setError("");
|
||||||
}, [kind, open, record]);
|
setConfirmLifecycleChange(false);
|
||||||
|
}, [initialDraft, open]);
|
||||||
|
|
||||||
async function save() {
|
const dirty = draftKey(draft) !== draftKey(baseline);
|
||||||
|
const incomplete = !draft.title.trim() || !draft.changeReason.trim();
|
||||||
|
|
||||||
|
async function save(closeAfter = true): Promise<boolean> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -91,30 +112,61 @@ export default function CommitteeRecordDialog({
|
|||||||
payload,
|
payload,
|
||||||
record?.revision
|
record?.revision
|
||||||
);
|
);
|
||||||
|
const nextDraft = draftFromRecord(kind, saved);
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setBaseline(nextDraft);
|
||||||
onSaved(saved);
|
onSaved(saved);
|
||||||
onClose();
|
if (closeAfter) onClose();
|
||||||
|
return true;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
|
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: open && dirty,
|
||||||
|
onSave: () => save(false),
|
||||||
|
onDiscard: () => setDraft(baseline),
|
||||||
|
title: "i18n:govoplan-committee.unsaved_title",
|
||||||
|
message: "i18n:govoplan-committee.unsaved_message"
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSave() {
|
||||||
|
if (record && draft.state !== record.state) {
|
||||||
|
setConfirmLifecycleChange(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title={`${record ? "Edit" : "New"} ${labelForKind(kind)}`}
|
title={i18nMessage("i18n:govoplan-committee.record_dialog_title", {
|
||||||
onClose={onClose}
|
action: translateText(record ? "Edit" : "New"),
|
||||||
|
kind: translateText(kindLabel(kind))
|
||||||
|
})}
|
||||||
|
onClose={requestClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
className="committee-record-dialog"
|
className="committee-record-dialog"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
<Button disabled={busy} disabledReason={committeeDisabledReason({ busy })} onClick={requestClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={busy || !draft.title.trim() || !draft.changeReason.trim()}
|
disabled={busy || incomplete}
|
||||||
onClick={() => void save()}
|
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
|
||||||
|
onClick={requestSave}
|
||||||
>
|
>
|
||||||
{busy ? "Saving" : "Save"}
|
{busy ? "Saving" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -122,9 +174,10 @@ export default function CommitteeRecordDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="committee-record-form">
|
<div className="committee-record-form">
|
||||||
|
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
|
||||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
<div className="committee-form-grid">
|
<div className="committee-form-grid">
|
||||||
<FormField label="Title">
|
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
@@ -132,19 +185,19 @@ export default function CommitteeRecordDialog({
|
|||||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="State">
|
<FormField label="State" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<select
|
<select
|
||||||
value={draft.state}
|
value={draft.state}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
|
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
|
||||||
>
|
>
|
||||||
{stateOptions.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kind === "body" ? (
|
{kind === "body" ? (
|
||||||
<FormField label="Responsible organization unit ID">
|
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
value={draft.organizationUnitId}
|
value={draft.organizationUnitId}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -183,7 +236,7 @@ export default function CommitteeRecordDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{draft.state === "decided" ? (
|
{draft.state === "decided" ? (
|
||||||
<FormField label="Formal Decision ID">
|
<FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
|
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -203,11 +256,11 @@ export default function CommitteeRecordDialog({
|
|||||||
<FormField label="Eligible voters">
|
<FormField label="Eligible voters">
|
||||||
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
|
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Ballot provider (optional)">
|
<FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Voting ballot ID (optional)">
|
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
|
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Choices (comma separated)">
|
<FormField label="Choices (comma separated)">
|
||||||
@@ -250,7 +303,7 @@ export default function CommitteeRecordDialog({
|
|||||||
|
|
||||||
{kind === "minute" ? (
|
{kind === "minute" ? (
|
||||||
<>
|
<>
|
||||||
<FormField label="Minutes record ID">
|
<FormField label="Minutes record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
|
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
{draft.state === "accepted" || draft.state === "corrected" ? (
|
{draft.state === "accepted" || draft.state === "corrected" ? (
|
||||||
@@ -259,7 +312,7 @@ export default function CommitteeRecordDialog({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FormField label="Change reason">
|
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
value={draft.changeReason}
|
value={draft.changeReason}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
@@ -268,6 +321,23 @@ export default function CommitteeRecordDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmLifecycleChange}
|
||||||
|
title="i18n:govoplan-committee.save_state_title"
|
||||||
|
message={record ? i18nMessage("i18n:govoplan-committee.confirm_state_change", {
|
||||||
|
kind: translateText(kindLabel(kind)),
|
||||||
|
from: translateText(stateLabel(record.state)),
|
||||||
|
to: translateText(stateLabel(draft.state))
|
||||||
|
}) : ""}
|
||||||
|
confirmLabel="Save"
|
||||||
|
tone={["cancelled", "retired", "withdrawn"].includes(draft.state) ? "danger" : "default"}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmLifecycleChange(false);
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmLifecycleChange(false)}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,10 +345,10 @@ export default function CommitteeRecordDialog({
|
|||||||
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
|
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="committee-form-grid">
|
<div className="committee-form-grid">
|
||||||
<FormField label="Approval ID">
|
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
|
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Evidence record ID">
|
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||||
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
|
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,6 +502,18 @@ function labelForKind(kind: CommitteeObjectKind): string {
|
|||||||
return humanize(kind);
|
return humanize(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind: CommitteeObjectKind): string {
|
||||||
|
return `i18n:govoplan-committee.kind_${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(state: string): string {
|
||||||
|
return `i18n:govoplan-committee.state_${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(draft: Draft): string {
|
||||||
|
return JSON.stringify(draft);
|
||||||
|
}
|
||||||
|
|
||||||
function humanize(value: string): string {
|
function humanize(value: string): string {
|
||||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const COMMITTEE_DOCUMENTATION = {
|
||||||
|
topicId: "committee.module-boundary",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const COMMITTEE_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "committee.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const COMMITTEE_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-committee.loading_reason",
|
||||||
|
busy: "i18n:govoplan-committee.busy_reason",
|
||||||
|
writeReason: "i18n:govoplan-committee.write_permission_reason",
|
||||||
|
finalizeReason: "i18n:govoplan-committee.finalize_permission_reason",
|
||||||
|
lifecycleReason: "i18n:govoplan-committee.lifecycle_reason",
|
||||||
|
incomplete: "i18n:govoplan-committee.incomplete_reason",
|
||||||
|
requiredAction: "i18n:govoplan-committee.required_action",
|
||||||
|
actor: "i18n:govoplan-committee.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-committee.destination",
|
||||||
|
permissionAction: "i18n:govoplan-committee.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-committee.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-committee.permission_destination"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function committeeDisabledReason({
|
||||||
|
loading = false,
|
||||||
|
busy = false,
|
||||||
|
permitted = true,
|
||||||
|
lifecycleBlocked = false
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
permitted?: boolean;
|
||||||
|
lifecycleBlocked?: boolean;
|
||||||
|
}): string | undefined {
|
||||||
|
if (loading) return COMMITTEE_INTERFACE_I18N.loading;
|
||||||
|
if (busy) return COMMITTEE_INTERFACE_I18N.busy;
|
||||||
|
if (!permitted) return COMMITTEE_INTERFACE_I18N.writeReason;
|
||||||
|
if (lifecycleBlocked) return COMMITTEE_INTERFACE_I18N.lifecycleReason;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-committee.committee": "Committee",
|
||||||
|
"i18n:govoplan-committee.loading_reason": "Committee data is still loading.",
|
||||||
|
"i18n:govoplan-committee.busy_reason": "Another Committee action is still running.",
|
||||||
|
"i18n:govoplan-committee.write_permission_reason": "Your account may not manage Committee records.",
|
||||||
|
"i18n:govoplan-committee.finalize_permission_reason": "Your account may not finalize governed ballot results.",
|
||||||
|
"i18n:govoplan-committee.lifecycle_reason": "This record is immutable in its current lifecycle state.",
|
||||||
|
"i18n:govoplan-committee.incomplete_reason": "Complete the required title and change reason first.",
|
||||||
|
"i18n:govoplan-committee.required_action": "Required action",
|
||||||
|
"i18n:govoplan-committee.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-committee.destination": "Destination",
|
||||||
|
"i18n:govoplan-committee.permission_action": "Ask for the corresponding Committee management permission.",
|
||||||
|
"i18n:govoplan-committee.permission_actor": "An Access or tenant administrator",
|
||||||
|
"i18n:govoplan-committee.permission_destination": "Access role assignments",
|
||||||
|
"i18n:govoplan-committee.confirm_state_change": "Save {kind} and change its state from {from} to {to}? The reason and revision are retained as evidence.",
|
||||||
|
"i18n:govoplan-committee.confirm_ballot_finalization": "Finalize {title}? The aggregate result, approval and evidence become part of the governed Committee record.",
|
||||||
|
"i18n:govoplan-committee.finalize_title": "Finalize ballot result",
|
||||||
|
"i18n:govoplan-committee.save_state_title": "Confirm lifecycle change",
|
||||||
|
"i18n:govoplan-committee.unsaved_title": "Unsaved Committee record",
|
||||||
|
"i18n:govoplan-committee.unsaved_message": "Save or discard the Committee record before leaving this surface.",
|
||||||
|
"i18n:govoplan-committee.votes_for": "Votes - {title}",
|
||||||
|
"i18n:govoplan-committee.finalize_provider_record": "Finalize {title} from ballot provider",
|
||||||
|
"i18n:govoplan-committee.record_reference": "Record {id}",
|
||||||
|
"i18n:govoplan-committee.edit_record": "Edit {title}",
|
||||||
|
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} cast",
|
||||||
|
"i18n:govoplan-committee.record_dialog_title": "{action} {kind}",
|
||||||
|
"i18n:govoplan-committee.kind_body": "body",
|
||||||
|
"i18n:govoplan-committee.kind_meeting": "meeting",
|
||||||
|
"i18n:govoplan-committee.kind_agenda_item": "agenda item",
|
||||||
|
"i18n:govoplan-committee.kind_vote": "vote",
|
||||||
|
"i18n:govoplan-committee.kind_minute": "minutes",
|
||||||
|
"i18n:govoplan-committee.state_draft": "Draft",
|
||||||
|
"i18n:govoplan-committee.state_active": "Active",
|
||||||
|
"i18n:govoplan-committee.state_suspended": "Suspended",
|
||||||
|
"i18n:govoplan-committee.state_retired": "Retired",
|
||||||
|
"i18n:govoplan-committee.state_scheduled": "Scheduled",
|
||||||
|
"i18n:govoplan-committee.state_open": "Open",
|
||||||
|
"i18n:govoplan-committee.state_closed": "Closed",
|
||||||
|
"i18n:govoplan-committee.state_cancelled": "Cancelled",
|
||||||
|
"i18n:govoplan-committee.state_deliberating": "Deliberating",
|
||||||
|
"i18n:govoplan-committee.state_decided": "Decided",
|
||||||
|
"i18n:govoplan-committee.state_withdrawn": "Withdrawn",
|
||||||
|
"i18n:govoplan-committee.state_proposed": "Proposed",
|
||||||
|
"i18n:govoplan-committee.state_accepted": "Accepted",
|
||||||
|
"i18n:govoplan-committee.state_corrected": "Corrected",
|
||||||
|
"i18n:govoplan-committee.domain_recorded": "Recorded",
|
||||||
|
"i18n:govoplan-committee.domain_public": "Public",
|
||||||
|
"i18n:govoplan-committee.domain_secret": "Secret",
|
||||||
|
"Committee": "Committee",
|
||||||
|
"Search bodies": "Search bodies",
|
||||||
|
"Search committee bodies": "Search committee bodies",
|
||||||
|
"Refresh": "Refresh",
|
||||||
|
"New body": "New body",
|
||||||
|
"Loading committee workspace": "Loading committee workspace",
|
||||||
|
"Committee bodies": "Committee bodies",
|
||||||
|
"Bodies": "Bodies",
|
||||||
|
"Edit body": "Edit body",
|
||||||
|
"New meeting": "New meeting",
|
||||||
|
"Meetings": "Meetings",
|
||||||
|
"Meeting workspace": "Meeting workspace",
|
||||||
|
"Select or create a meeting.": "Select or create a meeting.",
|
||||||
|
"Edit meeting": "Edit meeting",
|
||||||
|
"Agenda": "Agenda",
|
||||||
|
"Add item": "Add item",
|
||||||
|
"No agenda items.": "No agenda items.",
|
||||||
|
"Add vote": "Add vote",
|
||||||
|
"Minutes": "Minutes",
|
||||||
|
"Add minutes": "Add minutes",
|
||||||
|
"No records.": "No records.",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Save": "Save",
|
||||||
|
"Saving": "Saving",
|
||||||
|
"Title": "Title",
|
||||||
|
"State": "State",
|
||||||
|
"Responsible organization unit ID": "Responsible organization unit ID",
|
||||||
|
"Starts": "Starts",
|
||||||
|
"Ends": "Ends",
|
||||||
|
"Position": "Position",
|
||||||
|
"Subject type": "Subject type",
|
||||||
|
"Subject ID": "Subject ID",
|
||||||
|
"Formal Decision ID": "Formal Decision ID",
|
||||||
|
"Method": "Method",
|
||||||
|
"Eligible voters": "Eligible voters",
|
||||||
|
"Ballot provider (optional)": "Ballot provider (optional)",
|
||||||
|
"Voting ballot ID (optional)": "Voting ballot ID (optional)",
|
||||||
|
"Choices (comma separated)": "Choices (comma separated)",
|
||||||
|
"Votes cast": "Votes cast",
|
||||||
|
"Quorum": "Quorum",
|
||||||
|
"Met": "Met",
|
||||||
|
"Not met": "Not met",
|
||||||
|
"Approval ID": "Approval ID",
|
||||||
|
"Evidence record ID": "Evidence record ID",
|
||||||
|
"Minutes record ID": "Minutes record ID",
|
||||||
|
"Change reason": "Change reason",
|
||||||
|
"Provider ballot reference": "Provider ballot reference",
|
||||||
|
"Importing": "Importing",
|
||||||
|
"Finalize": "Finalize",
|
||||||
|
"Date not set": "Date not set",
|
||||||
|
"No Committee management permission": "No Committee management permission",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"New": "New",
|
||||||
|
"Recorded": "Recorded",
|
||||||
|
"Public": "Public",
|
||||||
|
"Secret": "Secret",
|
||||||
|
"Case": "Case",
|
||||||
|
"Service": "Service",
|
||||||
|
"Work Item": "Work item",
|
||||||
|
"Record": "Record"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-committee.committee": "Gremien",
|
||||||
|
"i18n:govoplan-committee.loading_reason": "Gremiendaten werden noch geladen.",
|
||||||
|
"i18n:govoplan-committee.busy_reason": "Eine andere Gremienaktion läuft noch.",
|
||||||
|
"i18n:govoplan-committee.write_permission_reason": "Ihr Konto darf Gremiendatensätze nicht verwalten.",
|
||||||
|
"i18n:govoplan-committee.finalize_permission_reason": "Ihr Konto darf geregelte Abstimmungsergebnisse nicht abschließen.",
|
||||||
|
"i18n:govoplan-committee.lifecycle_reason": "Dieser Datensatz ist in seinem aktuellen Lebenszyklus unveränderlich.",
|
||||||
|
"i18n:govoplan-committee.incomplete_reason": "Füllen Sie zuerst Titel und Änderungsgrund aus.",
|
||||||
|
"i18n:govoplan-committee.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-committee.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-committee.destination": "Ziel",
|
||||||
|
"i18n:govoplan-committee.permission_action": "Fordern Sie die entsprechende Berechtigung zur Gremienverwaltung an.",
|
||||||
|
"i18n:govoplan-committee.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
|
||||||
|
"i18n:govoplan-committee.permission_destination": "Zugriff und Rollenzuweisungen",
|
||||||
|
"i18n:govoplan-committee.confirm_state_change": "{kind} speichern und den Status von {from} auf {to} ändern? Grund und Revision werden als Nachweis aufbewahrt.",
|
||||||
|
"i18n:govoplan-committee.confirm_ballot_finalization": "{title} abschließen? Gesamtergebnis, Genehmigung und Nachweis werden Bestandteil des geregelten Gremiendatensatzes.",
|
||||||
|
"i18n:govoplan-committee.finalize_title": "Abstimmungsergebnis abschließen",
|
||||||
|
"i18n:govoplan-committee.save_state_title": "Lebenszyklusänderung bestätigen",
|
||||||
|
"i18n:govoplan-committee.unsaved_title": "Ungespeicherter Gremiendatensatz",
|
||||||
|
"i18n:govoplan-committee.unsaved_message": "Speichern oder verwerfen Sie den Gremiendatensatz, bevor Sie diese Oberfläche verlassen.",
|
||||||
|
"i18n:govoplan-committee.votes_for": "Abstimmungen - {title}",
|
||||||
|
"i18n:govoplan-committee.finalize_provider_record": "{title} über Abstimmungsanbieter abschließen",
|
||||||
|
"i18n:govoplan-committee.record_reference": "Datensatz {id}",
|
||||||
|
"i18n:govoplan-committee.edit_record": "{title} bearbeiten",
|
||||||
|
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} abgegeben",
|
||||||
|
"i18n:govoplan-committee.record_dialog_title": "{kind}: {action}",
|
||||||
|
"i18n:govoplan-committee.kind_body": "Gremium",
|
||||||
|
"i18n:govoplan-committee.kind_meeting": "Sitzung",
|
||||||
|
"i18n:govoplan-committee.kind_agenda_item": "Tagesordnungspunkt",
|
||||||
|
"i18n:govoplan-committee.kind_vote": "Abstimmung",
|
||||||
|
"i18n:govoplan-committee.kind_minute": "Protokoll",
|
||||||
|
"i18n:govoplan-committee.state_draft": "Entwurf",
|
||||||
|
"i18n:govoplan-committee.state_active": "Aktiv",
|
||||||
|
"i18n:govoplan-committee.state_suspended": "Ausgesetzt",
|
||||||
|
"i18n:govoplan-committee.state_retired": "Stillgelegt",
|
||||||
|
"i18n:govoplan-committee.state_scheduled": "Geplant",
|
||||||
|
"i18n:govoplan-committee.state_open": "Offen",
|
||||||
|
"i18n:govoplan-committee.state_closed": "Geschlossen",
|
||||||
|
"i18n:govoplan-committee.state_cancelled": "Abgesagt",
|
||||||
|
"i18n:govoplan-committee.state_deliberating": "In Beratung",
|
||||||
|
"i18n:govoplan-committee.state_decided": "Entschieden",
|
||||||
|
"i18n:govoplan-committee.state_withdrawn": "Zurückgezogen",
|
||||||
|
"i18n:govoplan-committee.state_proposed": "Vorgeschlagen",
|
||||||
|
"i18n:govoplan-committee.state_accepted": "Angenommen",
|
||||||
|
"i18n:govoplan-committee.state_corrected": "Berichtigt",
|
||||||
|
"i18n:govoplan-committee.domain_recorded": "Namentlich",
|
||||||
|
"i18n:govoplan-committee.domain_public": "Öffentlich",
|
||||||
|
"i18n:govoplan-committee.domain_secret": "Geheim",
|
||||||
|
"Committee": "Gremien",
|
||||||
|
"Search bodies": "Gremien suchen",
|
||||||
|
"Search committee bodies": "Gremien durchsuchen",
|
||||||
|
"Refresh": "Aktualisieren",
|
||||||
|
"New body": "Neues Gremium",
|
||||||
|
"Loading committee workspace": "Gremienarbeitsbereich wird geladen",
|
||||||
|
"Committee bodies": "Gremien",
|
||||||
|
"Bodies": "Gremien",
|
||||||
|
"Edit body": "Gremium bearbeiten",
|
||||||
|
"New meeting": "Neue Sitzung",
|
||||||
|
"Meetings": "Sitzungen",
|
||||||
|
"Meeting workspace": "Sitzungsarbeitsbereich",
|
||||||
|
"Select or create a meeting.": "Wählen oder erstellen Sie eine Sitzung.",
|
||||||
|
"Edit meeting": "Sitzung bearbeiten",
|
||||||
|
"Agenda": "Tagesordnung",
|
||||||
|
"Add item": "Punkt hinzufügen",
|
||||||
|
"No agenda items.": "Keine Tagesordnungspunkte.",
|
||||||
|
"Add vote": "Abstimmung hinzufügen",
|
||||||
|
"Minutes": "Protokolle",
|
||||||
|
"Add minutes": "Protokoll hinzufügen",
|
||||||
|
"No records.": "Keine Datensätze.",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Save": "Speichern",
|
||||||
|
"Saving": "Speichern",
|
||||||
|
"Title": "Titel",
|
||||||
|
"State": "Status",
|
||||||
|
"Responsible organization unit ID": "ID der zuständigen Organisationseinheit",
|
||||||
|
"Starts": "Beginn",
|
||||||
|
"Ends": "Ende",
|
||||||
|
"Position": "Position",
|
||||||
|
"Subject type": "Gegenstandsart",
|
||||||
|
"Subject ID": "Gegenstands-ID",
|
||||||
|
"Formal Decision ID": "Formelle Entscheidungs-ID",
|
||||||
|
"Method": "Verfahren",
|
||||||
|
"Eligible voters": "Stimmberechtigte",
|
||||||
|
"Ballot provider (optional)": "Abstimmungsanbieter (optional)",
|
||||||
|
"Voting ballot ID (optional)": "Abstimmungs-ID (optional)",
|
||||||
|
"Choices (comma separated)": "Auswahlmöglichkeiten (kommagetrennt)",
|
||||||
|
"Votes cast": "Abgegebene Stimmen",
|
||||||
|
"Quorum": "Quorum",
|
||||||
|
"Met": "Erfüllt",
|
||||||
|
"Not met": "Nicht erfüllt",
|
||||||
|
"Approval ID": "Genehmigungs-ID",
|
||||||
|
"Evidence record ID": "Nachweisdatensatz-ID",
|
||||||
|
"Minutes record ID": "Protokolldatensatz-ID",
|
||||||
|
"Change reason": "Änderungsgrund",
|
||||||
|
"Provider ballot reference": "Referenz des Abstimmungsanbieters",
|
||||||
|
"Importing": "Importieren",
|
||||||
|
"Finalize": "Abschließen",
|
||||||
|
"Date not set": "Datum nicht festgelegt",
|
||||||
|
"No Committee management permission": "Keine Berechtigung zur Gremienverwaltung",
|
||||||
|
"Edit": "Bearbeiten",
|
||||||
|
"New": "Neu",
|
||||||
|
"Recorded": "Namentlich",
|
||||||
|
"Public": "Öffentlich",
|
||||||
|
"Secret": "Geheim",
|
||||||
|
"Case": "Fall",
|
||||||
|
"Service": "Leistung",
|
||||||
|
"Work Item": "Arbeitsschritt",
|
||||||
|
"Record": "Datensatz"
|
||||||
|
};
|
||||||
|
|
||||||
|
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/committee.css";
|
import "./styles/committee.css";
|
||||||
|
|
||||||
|
|
||||||
@@ -7,13 +8,14 @@ const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
|
|||||||
|
|
||||||
export const committeeModule: PlatformWebModule = {
|
export const committeeModule: PlatformWebModule = {
|
||||||
id: "committee",
|
id: "committee",
|
||||||
name: "Committee",
|
label: "i18n:govoplan-committee.committee",
|
||||||
version: "0.1.8",
|
version: "0.1.8",
|
||||||
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
|
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
|
||||||
|
translations: generatedTranslations,
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/committee",
|
to: "/committee",
|
||||||
label: "Committee",
|
label: "i18n:govoplan-committee.committee",
|
||||||
iconName: "gavel",
|
iconName: "gavel",
|
||||||
anyOf: ["committee:workspace:read"],
|
anyOf: ["committee:workspace:read"],
|
||||||
order: 38
|
order: 38
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
margin: 10px 16px 0;
|
margin: 10px 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.committee-shell > .action-blocker-hint {
|
||||||
|
margin: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.committee-workspace {
|
.committee-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
|
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
|
||||||
@@ -259,6 +263,11 @@
|
|||||||
gap: 13px;
|
gap: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.committee-dialog-help {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.committee-form-grid,
|
.committee-form-grid,
|
||||||
.committee-count-grid {
|
.committee-count-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user