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