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
+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}`;
}