feat(campaigns): add accountable work assignments
Module Package Release / publish-packages (push) Successful in 14s

This commit is contained in:
2026-08-22 00:50:03 +02:00
parent c2f083e5f6
commit 2630498026
23 changed files with 3011 additions and 15 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.20",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -36,7 +36,8 @@
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs",
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs"
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs",
"test:campaign-work": "node tests/campaign-work-ui-structure.test.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
+157
View File
@@ -81,6 +81,63 @@ export type CampaignCollaborationCreate = {
mention_user_ids?: string[];
};
export type CampaignWorkAssigneeType = "account" | "group" | "organization_function";
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "cancelled";
export type CampaignWorkAssignmentResolutionState = "resolved" | "unavailable" | "provider_unavailable";
export type CampaignWorkAssignment = {
id: string;
campaign_id: string;
purpose: string;
status: CampaignWorkAssignmentStatus;
due_at?: string | null;
assignee_type: CampaignWorkAssigneeType;
assignee_id: string;
assignee_label_snapshot: string;
assignee_current_label?: string | null;
assignee_resolution_state: CampaignWorkAssignmentResolutionState;
resolution_provenance: Record<string, unknown>;
resolution_checked_at: string;
assigned_by_user_id?: string | null;
assigned_by_label: string;
reference?: CampaignCollaborationReference | null;
completed_at?: string | null;
cancelled_at?: string | null;
task_mirror_id?: string | null;
task_mirror_status: "not_configured" | "mirrored" | "failed" | "skipped";
task_mirror_error?: string | null;
resource_revision: number;
created_at: string;
updated_at: string;
};
export type CampaignWorkAssignmentListResponse = {
items: CampaignWorkAssignment[];
next_cursor?: string | null;
has_more: boolean;
};
export type CampaignWorkAssignmentEvent = {
id: string;
assignment_id: string;
event_kind: string;
actor_user_id?: string | null;
actor_label: string;
status: CampaignWorkAssignmentStatus;
assignee_type: CampaignWorkAssigneeType;
assignee_id: string;
assignee_label: string;
resolution_state: CampaignWorkAssignmentResolutionState;
details: Record<string, unknown>;
created_at: string;
};
export type CampaignWorkAssignmentHistoryResponse = {
items: CampaignWorkAssignmentEvent[];
next_cursor?: string | null;
has_more: boolean;
};
export type CampaignArchiveEncryptionPolicy = {
available: boolean;
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
@@ -1865,6 +1922,106 @@ export function campaignCollaborationMentionProvider(
);
}
export async function listCampaignWorkAssignments(
settings: ApiSettings,
campaignId: string,
options: { cursor?: string | null; limit?: number; statuses?: CampaignWorkAssignmentStatus[] } = {}
): Promise<CampaignWorkAssignmentListResponse> {
const params = new URLSearchParams({ limit: String(options.limit ?? 50) });
if (options.cursor) params.set("cursor", options.cursor);
for (const status of options.statuses ?? []) params.append("assignment_status", status);
return apiFetch<CampaignWorkAssignmentListResponse>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments?${params.toString()}`
);
}
export async function createCampaignWorkAssignment(
settings: ApiSettings,
campaignId: string,
payload: {
purpose: string;
assignee: { type: CampaignWorkAssigneeType; id: string };
due_at?: string | null;
reference?: Pick<CampaignCollaborationReference, "kind" | "id"> | null;
mirror_to_tasks?: boolean;
}
): Promise<CampaignWorkAssignment> {
return apiFetch<CampaignWorkAssignment>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function reassignCampaignWorkAssignment(
settings: ApiSettings,
campaignId: string,
assignmentId: string,
payload: {
expected_revision: number;
assignee: { type: CampaignWorkAssigneeType; id: string };
reason?: string | null;
mirror_to_tasks?: boolean;
}
): Promise<CampaignWorkAssignment> {
return apiFetch<CampaignWorkAssignment>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/reassign`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function transitionCampaignWorkAssignment(
settings: ApiSettings,
campaignId: string,
assignment: Pick<CampaignWorkAssignment, "id" | "resource_revision">,
action: "start" | "complete" | "cancel"
): Promise<CampaignWorkAssignment> {
return apiFetch<CampaignWorkAssignment>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignment.id)}/transition`,
{ method: "POST", body: JSON.stringify({ expected_revision: assignment.resource_revision, action }) }
);
}
export async function listCampaignWorkAssignmentHistory(
settings: ApiSettings,
campaignId: string,
assignmentId: string,
cursor?: string | null
): Promise<CampaignWorkAssignmentHistoryResponse> {
const params = new URLSearchParams({ limit: "50" });
if (cursor) params.set("cursor", cursor);
return apiFetch<CampaignWorkAssignmentHistoryResponse>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/history?${params.toString()}`
);
}
export async function reconcileCampaignWorkAssignments(
settings: ApiSettings,
campaignId: string
): Promise<{ checked: number; changed: number; assignments: CampaignWorkAssignment[] }> {
return apiFetch(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/reconcile`,
{ method: "POST" }
);
}
export function campaignWorkAssignmentTargetProvider(
settings: ApiSettings,
campaignId: string,
assigneeType: CampaignWorkAssigneeType
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/options`,
{ assignee_type: assigneeType }
);
}
export function campaignShareTargetProvider(
settings: ApiSettings,
campaignId: string,
@@ -0,0 +1,476 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
import {
Button,
Card,
ConfirmDialog,
DateTimeField,
Dialog,
DismissibleAlert,
FormField,
PageActionBar,
PageLayout,
ReferenceSelect,
StatusBadge,
hasScope
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo } from "../../types";
import {
campaignWorkAssignmentTargetProvider,
createCampaignWorkAssignment,
listCampaignWorkAssignmentHistory,
listCampaignWorkAssignments,
reassignCampaignWorkAssignment,
reconcileCampaignWorkAssignments,
transitionCampaignWorkAssignment,
type CampaignWorkAssigneeType,
type CampaignWorkAssignment,
type CampaignWorkAssignmentEvent
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
type AssignmentDraft = {
purpose: string;
assigneeType: CampaignWorkAssigneeType;
assigneeId: string;
dueAt: string;
versionId: string;
mirrorToTasks: boolean;
reason: string;
};
const EMPTY_DRAFT: AssignmentDraft = {
purpose: "",
assigneeType: "account",
assigneeId: "",
dueAt: "",
versionId: "",
mirrorToTasks: true,
reason: ""
};
export default function CampaignWorkPage({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
const workspace = useCampaignWorkspaceData(settings, campaignId);
const [assignments, setAssignments] = useState<CampaignWorkAssignment[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [busyId, setBusyId] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [draft, setDraft] = useState<AssignmentDraft>(EMPTY_DRAFT);
const [createOpen, setCreateOpen] = useState(false);
const [reassigning, setReassigning] = useState<CampaignWorkAssignment | null>(null);
const [cancelling, setCancelling] = useState<CampaignWorkAssignment | null>(null);
const [historyFor, setHistoryFor] = useState<CampaignWorkAssignment | null>(null);
const [history, setHistory] = useState<CampaignWorkAssignmentEvent[]>([]);
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const canManage = hasScope(auth, "campaigns:assignment:manage");
const canComplete = hasScope(auth, "campaigns:assignment:complete");
const targetProvider = useMemo(
() => campaignWorkAssignmentTargetProvider(settings, campaignId, draft.assigneeType),
[campaignId, draft.assigneeType, settings]
);
const loadAssignments = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await listCampaignWorkAssignments(settings, campaignId);
setAssignments(response.items);
setNextCursor(response.next_cursor ?? null);
setHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [campaignId, settings]);
useEffect(() => {
void loadAssignments();
}, [loadAssignments]);
function replaceAssignment(updated: CampaignWorkAssignment) {
setAssignments((current) => current.map((item) => item.id === updated.id ? updated : item));
}
async function reload() {
setMessage("");
await Promise.all([loadAssignments(), workspace.reload({ force: true })]);
}
async function loadMore() {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
setError("");
try {
const response = await listCampaignWorkAssignments(settings, campaignId, { cursor: nextCursor });
setAssignments((current) => [
...current,
...response.items.filter((item) => !current.some((existing) => existing.id === item.id))
]);
setNextCursor(response.next_cursor ?? null);
setHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setLoadingMore(false);
}
}
async function createAssignment() {
if (!draft.purpose.trim() || !draft.assigneeId || busyId) return;
setBusyId("create");
setError("");
try {
const created = await createCampaignWorkAssignment(settings, campaignId, {
purpose: draft.purpose.trim(),
assignee: { type: draft.assigneeType, id: draft.assigneeId },
due_at: draft.dueAt ? new Date(draft.dueAt).toISOString() : null,
reference: draft.versionId ? { kind: "campaign_version", id: draft.versionId } : null,
mirror_to_tasks: draft.mirrorToTasks
});
setAssignments((current) => [created, ...current]);
setDraft(EMPTY_DRAFT);
setCreateOpen(false);
setMessage("Work assignment created. Access and ownership were not changed.");
} catch (err) {
setError(errorText(err));
} finally {
setBusyId("");
}
}
async function reassign() {
if (!reassigning || !draft.assigneeId || busyId) return;
setBusyId(reassigning.id);
setError("");
try {
const updated = await reassignCampaignWorkAssignment(settings, campaignId, reassigning.id, {
expected_revision: reassigning.resource_revision,
assignee: { type: draft.assigneeType, id: draft.assigneeId },
reason: draft.reason.trim() || null,
mirror_to_tasks: draft.mirrorToTasks
});
replaceAssignment(updated);
setReassigning(null);
setDraft(EMPTY_DRAFT);
setMessage("Work reassigned; the previous target remains in history.");
} catch (err) {
setError(errorText(err));
} finally {
setBusyId("");
}
}
async function transition(assignment: CampaignWorkAssignment, action: "start" | "complete" | "cancel") {
if (busyId) return;
setBusyId(assignment.id);
setError("");
try {
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
replaceAssignment(updated);
setCancelling(null);
setMessage(`Work ${action === "start" ? "started" : action === "complete" ? "completed" : "cancelled"}.`);
} catch (err) {
setError(errorText(err));
} finally {
setBusyId("");
}
}
async function reconcile() {
if (busyId) return;
setBusyId("reconcile");
setError("");
try {
const result = await reconcileCampaignWorkAssignments(settings, campaignId);
await loadAssignments();
setMessage(`Checked ${result.checked} active assignments; ${result.changed} resolution state${result.changed === 1 ? "" : "s"} changed.`);
} catch (err) {
setError(errorText(err));
} finally {
setBusyId("");
}
}
async function openHistory(assignment: CampaignWorkAssignment) {
setHistoryFor(assignment);
setHistory([]);
setHistoryLoading(true);
setError("");
try {
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, assignment.id);
setHistory(response.items);
setHistoryCursor(response.next_cursor ?? null);
setHistoryHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setHistoryLoading(false);
}
}
async function loadOlderHistory() {
if (!historyFor || !historyCursor || historyLoading) return;
setHistoryLoading(true);
try {
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, historyFor.id, historyCursor);
setHistory((current) => [...current, ...response.items]);
setHistoryCursor(response.next_cursor ?? null);
setHistoryHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setHistoryLoading(false);
}
}
function beginCreate() {
setDraft({ ...EMPTY_DRAFT, versionId: workspace.data.campaign?.current_version_id ?? "" });
setCreateOpen(true);
}
function beginReassign(assignment: CampaignWorkAssignment) {
setDraft({
...EMPTY_DRAFT,
assigneeType: assignment.assignee_type,
assigneeId: assignment.assignee_id
});
setReassigning(assignment);
}
return (
<PageLayout
archetype="collection"
mode="workspace"
interfaceId="campaigns.page.work"
helpContextId="campaign.work"
helpModuleId="campaign"
title="Campaign work"
description="Assign accountable work without granting Campaign access or transferring ownership."
loading={loading || workspace.loading}
loadingLabel="Loading Campaign work…"
error={error || workspace.error}
success={message}
actions={
<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading: loading || workspace.loading }}
createAction={canManage ? <Button variant="primary" onClick={beginCreate} helpContextId="campaign.work.create" helpModuleId="campaign"><Plus size={16} aria-hidden="true" /> Add assignment</Button> : null}
contextActions={canManage ? <Button onClick={() => void reconcile()} disabled={Boolean(busyId)}><RefreshCw size={16} aria-hidden="true" /> Reconcile assignees</Button> : null}
/>
}
>
<DismissibleAlert tone="info" resetKey={campaignId}>
Assignment records responsibility only. Campaign sharing, ownership transfer, approval and Audit remain separate governed surfaces.
</DismissibleAlert>
<Card title="Accountable work" interfaceId="campaigns.work.list" helpContextId="campaign.work" helpModuleId="campaign">
{assignments.length === 0 ? (
<p className="muted">No work has been assigned for this Campaign.</p>
) : (
<ol className="campaign-work-list" aria-label="Campaign work assignments">
{assignments.map((assignment) => (
<li key={assignment.id} className="campaign-work-item">
<article>
<header className="campaign-work-item-header">
<div>
<h3>{assignment.purpose}</h3>
<p className="muted small-note">
Assigned to <strong>{assignment.assignee_current_label || assignment.assignee_label_snapshot}</strong>
{` · ${assignment.assignee_type.replace(/_/g, " ")}`}
</p>
</div>
<div className="campaign-work-badges">
<StatusBadge status={assignment.status} label={assignment.status.replace(/_/g, " ")} />
<StatusBadge
status={assignment.assignee_resolution_state === "resolved" ? "active" : "warning"}
label={assignment.assignee_resolution_state.replace(/_/g, " ")}
/>
</div>
</header>
<dl className="campaign-work-meta">
<div><dt>Assigned by</dt><dd>{assignment.assigned_by_label}</dd></div>
<div><dt>Due</dt><dd>{assignment.due_at ? formatDate(assignment.due_at) : "No due date"}</dd></div>
<div><dt>Reference</dt><dd>{assignment.reference?.label ?? "Campaign"}</dd></div>
<div><dt>Tasks mirror</dt><dd>{assignment.task_mirror_status.replace(/_/g, " ")}</dd></div>
</dl>
{assignment.assignee_resolution_state !== "resolved" ? (
<DismissibleAlert tone="warning" resetKey={`${assignment.id}:${assignment.resource_revision}`}>
This target is no longer available or cannot currently be resolved. History is retained; reassign it or restore the separate Campaign access/directory relationship.
</DismissibleAlert>
) : null}
<div className="button-row compact-actions campaign-work-actions">
<Button onClick={() => void openHistory(assignment)} disabled={busyId === assignment.id} helpContextId="campaign.work.history" helpModuleId="campaign">
<History size={16} aria-hidden="true" /> History
</Button>
{canComplete && assignment.status === "open" ? (
<Button onClick={() => void transition(assignment, "start")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
<Play size={16} aria-hidden="true" /> Start
</Button>
) : null}
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
<Button variant="primary" onClick={() => void transition(assignment, "complete")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.complete" helpModuleId="campaign">
<CheckCircle2 size={16} aria-hidden="true" /> Complete
</Button>
) : null}
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
<Button onClick={() => beginReassign(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.reassign" helpModuleId="campaign">
<UserRoundCog size={16} aria-hidden="true" /> Reassign
</Button>
) : null}
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
<span className="campaign-work-destructive-action">
<Button variant="danger" onClick={() => setCancelling(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.cancel" helpModuleId="campaign">
Cancel work
</Button>
</span>
) : null}
</div>
</article>
</li>
))}
</ol>
)}
{hasMore ? (
<div className="button-row compact-actions campaign-work-load-more">
<Button onClick={() => void loadMore()} disabled={loadingMore}>{loadingMore ? "Loading…" : "Load older work"}</Button>
</div>
) : null}
</Card>
<Dialog
open={createOpen}
title="Add Campaign work assignment"
onClose={() => !busyId && setCreateOpen(false)}
footer={<><Button onClick={() => setCreateOpen(false)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void createAssignment()} disabled={Boolean(busyId) || !draft.purpose.trim() || !draft.assigneeId}>Create assignment</Button></>}
>
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={workspace.data.versions} busy={Boolean(busyId)} includePurpose />
</Dialog>
<Dialog
open={Boolean(reassigning)}
title="Reassign Campaign work"
onClose={() => !busyId && setReassigning(null)}
footer={<><Button onClick={() => setReassigning(null)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void reassign()} disabled={Boolean(busyId) || !draft.assigneeId}>Reassign</Button></>}
>
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={[]} busy={Boolean(busyId)} includeReason />
</Dialog>
<Dialog
open={Boolean(historyFor)}
title={historyFor ? `History: ${historyFor.purpose}` : "Assignment history"}
onClose={() => !historyLoading && setHistoryFor(null)}
footer={<Button onClick={() => setHistoryFor(null)} disabled={historyLoading}>Close</Button>}
>
{historyLoading && history.length === 0 ? <p className="muted">Loading history</p> : (
<ol className="campaign-work-history">
{history.map((event) => (
<li key={event.id}>
<div><StatusBadge status={event.status} /> <strong>{event.event_kind.replace(/_/g, " ")}</strong></div>
<p>{event.assignee_label} · {event.resolution_state.replace(/_/g, " ")}</p>
<small>{event.actor_label} · {formatDate(event.created_at)}</small>
</li>
))}
</ol>
)}
{historyHasMore ? <Button onClick={() => void loadOlderHistory()} disabled={historyLoading}>Load older history</Button> : null}
</Dialog>
<ConfirmDialog
open={Boolean(cancelling)}
title="Cancel assigned work?"
message="The work will close without being completed. Its purpose, assignee and transition history remain as durable evidence."
confirmLabel="Cancel work"
tone="danger"
busy={Boolean(busyId)}
onCancel={() => setCancelling(null)}
onConfirm={() => cancelling ? void transition(cancelling, "cancel") : undefined}
/>
</PageLayout>
);
}
function AssignmentFields({
draft,
setDraft,
provider,
versions,
busy,
includePurpose = false,
includeReason = false
}: {
draft: AssignmentDraft;
setDraft: (draft: AssignmentDraft) => void;
provider: ReturnType<typeof campaignWorkAssignmentTargetProvider>;
versions: Array<{ id: string; version_number: number }>;
busy: boolean;
includePurpose?: boolean;
includeReason?: boolean;
}) {
return (
<div className="campaign-work-form">
{includePurpose ? (
<FormField label="Purpose" help="Describe one bounded outcome. This text is retained in assignment history and may be mirrored to Tasks." helpContextId="campaign.work.create" helpModuleId="campaign">
<textarea rows={3} maxLength={500} value={draft.purpose} disabled={busy} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} />
</FormField>
) : null}
<FormField label="Assignee type" help="The selected target must already have Campaign access; assigning work never grants it.">
<select value={draft.assigneeType} disabled={busy} onChange={(event) => setDraft({ ...draft, assigneeType: event.target.value as CampaignWorkAssigneeType, assigneeId: "" })}>
<option value="account">Account</option>
<option value="group">Group</option>
<option value="organization_function">Organization function</option>
</select>
</FormField>
<FormField label="Assignee">
<ReferenceSelect value={draft.assigneeId} onChange={(assigneeId) => setDraft({ ...draft, assigneeId })} provider={provider} disabled={busy} placeholder="Search authorized targets" />
</FormField>
{includePurpose ? (
<FormField label="Due at" help="Optional; shown in Campaign work and an available Tasks mirror.">
<DateTimeField value={draft.dueAt} onChange={(dueAt) => setDraft({ ...draft, dueAt })} disabled={busy} />
</FormField>
) : null}
{includePurpose && versions.length > 0 ? (
<FormField label="Campaign evidence reference" help="Optionally attach this work to one immutable Campaign version.">
<select value={draft.versionId} disabled={busy} onChange={(event) => setDraft({ ...draft, versionId: event.target.value })}>
<option value="">Campaign only</option>
{versions.map((version) => <option key={version.id} value={version.id}>Version {version.version_number}</option>)}
</select>
</FormField>
) : null}
{includeReason ? (
<FormField label="Reason" help="Optional bounded context retained with the reassignment event.">
<textarea rows={3} maxLength={500} value={draft.reason} disabled={busy} onChange={(event) => setDraft({ ...draft, reason: event.target.value })} />
</FormField>
) : null}
<label className="checkbox-row">
<input type="checkbox" checked={draft.mirrorToTasks} disabled={busy} onChange={(event) => setDraft({ ...draft, mirrorToTasks: event.target.checked })} />
Mirror into Tasks when the optional provider is available
</label>
<p className="muted small-note">Tasks is only a projection. Campaign remains authoritative, and no notification or mirror grants access.</p>
</div>
);
}
function formatDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : "Campaign work could not be updated.";
}
@@ -25,6 +25,7 @@ const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
const CampaignWorkPage = lazy(() => import("./CampaignWorkPage"));
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
overview: "",
@@ -41,6 +42,7 @@ const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
review: "review",
report: "report",
activity: "activity",
work: "work",
audit: "audit",
json: "json"
};
@@ -90,7 +92,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
return (
<WorkspaceLayout
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} />}
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} canReadWork={hasScope(auth, "campaigns:assignment:read")} />}
primaryLabel="Campaign sections"
contentLabel="Campaign workspace"
interfaceId="campaign.workspace"
@@ -116,6 +118,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
@@ -153,6 +156,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceNavigationSection {
if (section === "send") return "review";
if (section === "report" || section === "reports") return "report";
if (section === "activity" || section === "collaboration") return "activity";
if (section === "work" || section === "assignments") return "work";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";
+9 -6
View File
@@ -1,7 +1,7 @@
import type { CampaignWorkspaceSection } from "../types";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity";
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity" | "work";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] = [
{
@@ -40,6 +40,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] =
title: "i18n:govoplan-campaign.report.7b8ddb90",
items: [
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
{ id: "work", label: "Campaign work" },
{ id: "activity", label: "i18n:govoplan-campaign.collaboration" },
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
@@ -53,18 +54,20 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] =
export default function SectionSidebar({
active,
onSelect,
canReadActivity
canReadActivity,
canReadWork
}: {
active: CampaignWorkspaceNavigationSection;
onSelect: (section: CampaignWorkspaceNavigationSection) => void;
canReadActivity: boolean;
canReadWork: boolean;
}) {
const groups = campaignSubnav.map((group) => ({
...group,
items: group.items.filter((item) => item.id !== "activity" || canReadActivity)
items: group.items.filter((item) =>
(item.id !== "activity" || canReadActivity)
&& (item.id !== "work" || canReadWork)
)
}));
return <ModuleSubnav active={active} groups={groups} onSelect={onSelect} />;
}
+8 -1
View File
@@ -88,9 +88,16 @@ export const campaignModule: PlatformWebModule = {
label: "i18n:govoplan-campaign.campaigns.01a23a28",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
translations,
viewSurfaces: [
{
id: "campaigns.page.work",
moduleId: "campaigns",
kind: "page",
label: "Campaign work",
order: 44
},
{
id: "campaigns.page.activity",
moduleId: "campaigns",
+23
View File
@@ -2799,9 +2799,32 @@
.campaign-collaboration-tombstone { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 9px 11px; border: var(--border-line); border-radius: var(--radius-sm); color: var(--muted); background: var(--subtle-bg); font-style: italic; }
.campaign-collaboration-entry-actions { justify-content: flex-end; padding-top: 4px; border-top: var(--border-line); }
.campaign-collaboration-load-more { justify-content: center; margin-top: 14px; }
.campaign-work-list,
.campaign-work-history { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.campaign-work-item { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
.campaign-work-item article { display: grid; gap: 14px; padding: 16px; }
.campaign-work-item-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; }
.campaign-work-item-header h3 { margin: 0 0 4px; font-size: var(--font-size-md); }
.campaign-work-item-header p { margin: 0; }
.campaign-work-badges { display: flex; flex-wrap: wrap; gap: 8px; }
.campaign-work-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0; }
.campaign-work-meta div { min-width: 0; }
.campaign-work-meta dt { color: var(--muted); font-size: var(--font-size-sm); }
.campaign-work-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.campaign-work-actions { align-items: center; padding-top: 4px; border-top: var(--border-line); }
.campaign-work-destructive-action { display: inline-flex; margin-inline-start: auto; padding-inline-start: 16px; border-inline-start: var(--border-line); }
.campaign-work-load-more { justify-content: center; margin-top: 14px; }
.campaign-work-form { display: grid; gap: 14px; }
.campaign-work-form textarea,
.campaign-work-form select { width: 100%; }
.campaign-work-history li { display: grid; gap: 5px; padding: 12px; border: var(--border-line); border-radius: var(--radius-sm); }
.campaign-work-history p,
.campaign-work-history small { margin: 0; }
@media (max-width: 760px) {
.campaign-collaboration-options { grid-template-columns: minmax(0, 1fr); }
.campaign-collaboration-submit,
.campaign-collaboration-entry-actions { justify-content: flex-start; }
.campaign-work-meta { grid-template-columns: minmax(0, 1fr); }
.campaign-work-destructive-action { width: 100%; margin-inline-start: 0; padding: 12px 0 0; border-inline-start: 0; border-top: var(--border-line); }
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(new URL("../src/features/campaigns/CampaignWorkPage.tsx", import.meta.url), "utf8");
const api = readFileSync(new URL("../src/api/campaigns.ts", import.meta.url), "utf8");
const workspace = readFileSync(new URL("../src/features/campaigns/CampaignWorkspace.tsx", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/layout/SectionSidebar.tsx", import.meta.url), "utf8");
for (const primitive of [
"PageLayout",
"PageActionBar",
"Card",
"FormField",
"ReferenceSelect",
"DateTimeField",
"Dialog",
"ConfirmDialog",
"StatusBadge",
"DismissibleAlert"
]) {
assert.match(page, new RegExp(`\\b${primitive}\\b`), `Campaign work should use centralized ${primitive}`);
}
assert.match(page, /archetype="collection"/);
assert.match(page, /refreshable/);
assert.match(page, /createAction=/);
assert.match(page, /campaign-work-destructive-action/);
assert.match(page, /tone="danger"/);
assert.match(page, /assignment records responsibility only/i);
assert.match(page, /Campaign sharing, ownership transfer, approval and Audit remain separate/i);
assert.match(page, /Reconcile assignees/);
assert.match(page, /History/);
for (const contract of [
"/assignments?",
"/assignments/${encodeURIComponent(assignment.id)}/transition",
"/assignments/${encodeURIComponent(assignmentId)}/reassign",
"/assignments/${encodeURIComponent(assignmentId)}/history",
"/assignments/reconcile",
"/assignments/options"
]) {
assert.ok(api.includes(contract), `Campaign API should expose ${contract}`);
}
assert.match(workspace, /path="work"/);
assert.match(workspace, /campaigns:assignment:read/);
assert.match(sidebar, /id: "work"/);
assert.match(sidebar, /canReadWork/);
console.log("campaign work UI structure tests passed");