Migrate Committee interface patterns

This commit is contained in:
2026-08-03 12:48:55 +02:00
parent 11b946ea78
commit e64af30534
10 changed files with 719 additions and 79 deletions
+42
View File
@@ -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={
"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": [
"committee bodies",
"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(
@@ -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 {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
@@ -11,6 +16,10 @@ import {
finalizeVotingBallot,
type CommitteeRecord
} from "../../api/committee";
import {
COMMITTEE_FIELD_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N
} from "./interfacePatterns";
export default function CommitteeBallotDialog({
@@ -31,6 +40,8 @@ export default function CommitteeBallotDialog({
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirming, setConfirming] = useState(false);
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open) return;
@@ -38,9 +49,12 @@ export default function CommitteeBallotDialog({
setApprovalId("");
setChangeReason("Imported verified ballot aggregate.");
setError("");
setConfirming(false);
}, [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);
setError("");
try {
@@ -52,31 +66,54 @@ export default function CommitteeBallotDialog({
changeReason
});
onSaved(saved);
onClose();
if (closeAfter) onClose();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
return false;
} finally {
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 votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
const incomplete = (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim();
return (
<>
<Dialog
open={open}
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
onClose={onClose}
onClose={requestClose}
closeDisabled={busy}
portal
className="committee-ballot-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button disabled={busy} disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : undefined} onClick={requestClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
onClick={() => void finalize()}
disabled={busy || incomplete}
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
onClick={() => setConfirming(true)}
>
{busy ? "Importing" : "Finalize"}
</Button>
@@ -84,22 +121,36 @@ export default function CommitteeBallotDialog({
}
>
<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}
<p className="committee-dialog-note">
{votingBallotId
? <>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.</>}
</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)} />
</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)} />
</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)} />
</FormField>
</div>
</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)}
/>
</>
);
}
+138 -47
View File
@@ -11,13 +11,17 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
ActionBlockerHint,
Button,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
i18nMessage,
usePlatformLanguage,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -27,6 +31,11 @@ import {
} from "../../api/committee";
import CommitteeBallotDialog from "./CommitteeBallotDialog";
import CommitteeRecordDialog from "./CommitteeRecordDialog";
import {
COMMITTEE_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N,
committeeDisabledReason
} from "./interfacePatterns";
import { canReviseCommitteeRecord } from "./lifecycle";
@@ -37,6 +46,7 @@ type EditorTarget = {
};
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
const { language, translateText } = usePlatformLanguage();
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const canWrite = hasScope(auth, "committee:workspace:write");
const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize");
@@ -165,9 +175,9 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
const meetingTime = useMemo(
() => 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 (
@@ -178,20 +188,42 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
<Search size={16} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
</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" />
Refresh
</Button>
{canWrite ? (
<Button variant="primary" onClick={() => setEditor({ kind: "body" })}>
<Plus size={16} aria-hidden="true" />
New body
</Button>
) : null}
<Button
variant="primary"
disabled={!canWrite || loading}
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
onClick={() => setEditor({ kind: "body" })}
>
<Plus size={16} aria-hidden="true" />
New body
</Button>
<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />
</div>
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : 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">
<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">
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
</PageScrollViewport>
{selectedBody && canWrite ? (
{selectedBody ? (
<div className="committee-panel-actions">
{canReviseCommitteeRecord(selectedBody) ? <IconButton label="Edit body" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null}
<Button variant="primary" onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}>
<IconButton
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" />
New meeting
</Button>
@@ -213,7 +256,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
<PanelHeading title="Meetings" count={meetings.length} />
<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>
</section>
@@ -227,27 +270,44 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
<span>{meetingTime}</span>
<h1>{selectedMeeting.title}</h1>
</div>
<StatusBadge status={statusTone(selectedMeeting.state)} label={humanize(selectedMeeting.state)} />
{canWrite && canReviseCommitteeRecord(selectedMeeting) ? <IconButton label="Edit meeting" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null}
<StatusBadge status={statusTone(selectedMeeting.state)} label={stateLabel(selectedMeeting.state)} />
<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>
<PageScrollViewport className="committee-detail-scroll">
<WorkspaceSection
title="Agenda"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}>
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}
>
<ListPlus size={16} aria-hidden="true" />
Add item
</Button>
) : null}
)}
>
<div className="committee-agenda-list">
{agendaItems.map((item) => (
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
<button type="button" onClick={() => setAgendaId(item.object_id)}>
<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>
{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>
))}
{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 ? (
<WorkspaceSection
title={`Votes - ${selectedAgenda.title}`}
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}>
title={i18nMessage("i18n:govoplan-committee.votes_for", { title: selectedAgenda.title })}
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}
>
<Vote size={16} aria-hidden="true" />
Add vote
</Button>
) : null}
)}
>
<RecordRows
records={votes}
canEdit={(record) => canWrite && canReviseCommitteeRecord(record)}
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
onEdit={(record) => setEditor({ kind: "vote", record })}
detail={voteSummary}
secondaryAction={(record) => canFinalizeBallot && isProviderBallotReady(record) ? (
detail={(record) => voteSummary(record, translateText)}
secondaryAction={(record) => isProviderBallotReady(record) ? (
<IconButton
label={`Finalize ${record.title} from ballot provider`}
label={i18nMessage("i18n:govoplan-committee.finalize_provider_record", { title: record.title })}
icon={<ShieldCheck size={15} />}
disabled={!canFinalizeBallot}
disabledReason={!canFinalizeBallot ? COMMITTEE_INTERFACE_I18N.finalizeReason : undefined}
onClick={() => setBallotRecord(record)}
/>
) : null}
@@ -282,14 +349,24 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
<WorkspaceSection
title="Minutes"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}>
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}
>
<FilePlus2 size={16} aria-hidden="true" />
Add minutes
</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>
</PageScrollViewport>
</>
@@ -337,7 +414,7 @@ function RecordList({ records, selectedId, onSelect, secondary }: {
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)}>
<strong>{record.title}</strong>
<span>{secondary?.(record) ?? humanize(record.state)}</span>
<span>{secondary?.(record) ?? stateLabel(record.state)}</span>
</button>
))}</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>;
}
function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryAction }: {
records: CommitteeRecord[];
canEdit: (record: CommitteeRecord) => boolean;
editDisabledReason: (record: CommitteeRecord) => string | undefined;
onEdit: (record: CommitteeRecord) => void;
detail: (record: CommitteeRecord) => string;
secondaryAction?: (record: CommitteeRecord) => ReactNode;
@@ -357,23 +434,33 @@ function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
return <div className="committee-record-rows">{records.map((record) => (
<div key={record.object_id}>
<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">
{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>
</div>
))}</div>;
}
function meetingSecondary(record: CommitteeRecord): string {
return `${formatDateTime(record.attributes.starts_at)} - ${humanize(record.state)}`;
function meetingSecondary(record: CommitteeRecord, locale: string, translateText: (value: string) => string): string {
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 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 {
@@ -389,16 +476,20 @@ function statusTone(state: string): "active" | "inactive" | "warning" {
return "warning";
}
function formatDateTime(value: unknown): string {
function formatDateTime(value: unknown, locale?: string): string {
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 "-";
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 {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
function stateLabel(value: string): string {
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 {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
@@ -12,6 +18,11 @@ import {
type CommitteeRecord
} from "../../api/committee";
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
import {
COMMITTEE_FIELD_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N,
committeeDisabledReason
} from "./interfacePatterns";
type Draft = {
title: string;
@@ -56,9 +67,14 @@ export default function CommitteeRecordDialog({
onClose: () => 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 [error, setError] = useState("");
const [confirmLifecycleChange, setConfirmLifecycleChange] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const choices = useMemo(
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
[draft.choices]
@@ -70,11 +86,16 @@ export default function CommitteeRecordDialog({
useEffect(() => {
if (!open) return;
setDraft(draftFromRecord(kind, record));
setDraft(initialDraft);
setBaseline(initialDraft);
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);
setError("");
try {
@@ -91,30 +112,61 @@ export default function CommitteeRecordDialog({
payload,
record?.revision
);
const nextDraft = draftFromRecord(kind, saved);
setDraft(nextDraft);
setBaseline(nextDraft);
onSaved(saved);
onClose();
if (closeAfter) onClose();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
return false;
} finally {
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 (
<Dialog
open={open}
title={`${record ? "Edit" : "New"} ${labelForKind(kind)}`}
onClose={onClose}
title={i18nMessage("i18n:govoplan-committee.record_dialog_title", {
action: translateText(record ? "Edit" : "New"),
kind: translateText(kindLabel(kind))
})}
onClose={requestClose}
closeDisabled={busy}
portal
className="committee-record-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button disabled={busy} disabledReason={committeeDisabledReason({ busy })} onClick={requestClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !draft.title.trim() || !draft.changeReason.trim()}
onClick={() => void save()}
disabled={busy || incomplete}
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
onClick={requestSave}
>
{busy ? "Saving" : "Save"}
</Button>
@@ -122,9 +174,10 @@ export default function CommitteeRecordDialog({
}
>
<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}
<div className="committee-form-grid">
<FormField label="Title">
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.title}
maxLength={500}
@@ -132,19 +185,19 @@ export default function CommitteeRecordDialog({
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</FormField>
<FormField label="State">
<FormField label="State" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<select
value={draft.state}
disabled={busy}
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>
</FormField>
</div>
{kind === "body" ? (
<FormField label="Responsible organization unit ID">
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.organizationUnitId}
disabled={busy}
@@ -183,7 +236,7 @@ export default function CommitteeRecordDialog({
</FormField>
</div>
{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 })} />
</FormField>
) : null}
@@ -203,11 +256,11 @@ export default function CommitteeRecordDialog({
<FormField label="Eligible voters">
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
</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 })} />
</FormField>
</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 })} />
</FormField>
<FormField label="Choices (comma separated)">
@@ -250,7 +303,7 @@ export default function CommitteeRecordDialog({
{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 })} />
</FormField>
{draft.state === "accepted" || draft.state === "corrected" ? (
@@ -259,7 +312,7 @@ export default function CommitteeRecordDialog({
</>
) : null}
<FormField label="Change reason">
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.changeReason}
maxLength={1000}
@@ -268,6 +321,23 @@ export default function CommitteeRecordDialog({
/>
</FormField>
</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>
);
}
@@ -275,10 +345,10 @@ export default function CommitteeRecordDialog({
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return (
<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 })} />
</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 })} />
</FormField>
</div>
@@ -432,6 +502,18 @@ function labelForKind(kind: CommitteeObjectKind): string {
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 {
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;
}
+223
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/committee.css";
@@ -7,13 +8,14 @@ const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
export const committeeModule: PlatformWebModule = {
id: "committee",
name: "Committee",
label: "i18n:govoplan-committee.committee",
version: "0.1.8",
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
translations: generatedTranslations,
navItems: [
{
to: "/committee",
label: "Committee",
label: "i18n:govoplan-committee.committee",
iconName: "gavel",
anyOf: ["committee:workspace:read"],
order: 38
+9
View File
@@ -38,6 +38,10 @@
margin: 10px 16px 0;
}
.committee-shell > .action-blocker-hint {
margin: 10px 16px 0;
}
.committee-workspace {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
@@ -259,6 +263,11 @@
gap: 13px;
}
.committee-dialog-help {
display: flex;
justify-content: flex-end;
}
.committee-form-grid,
.committee-count-grid {
display: grid;