Migrate Committee interface patterns
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user