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
@@ -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;