Files
govoplan-campaign/webui/src/features/campaigns/CampaignWorkPage.tsx
T
zemion 1bd24f9b5b
Module Package Release / publish-packages (push) Successful in 13s
feat: orchestrate accountable Campaign work
2026-08-22 02:14:35 +02:00

519 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
import { useSearchParams } from "react-router";
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 [searchParams] = useSearchParams();
const requestedAssignmentId = searchParams.get("assignment");
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 [rejecting, setRejecting] = 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]);
useEffect(() => {
if (!requestedAssignmentId || loading) return;
document
.getElementById(`campaign-assignment-${requestedAssignmentId}`)
?.focus({ preventScroll: false });
}, [assignments, loading, requestedAssignmentId]);
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: "accept" | "start" | "complete" | "reject" | "cancel") {
if (busyId) return;
setBusyId(assignment.id);
setError("");
try {
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
replaceAssignment(updated);
setCancelling(null);
setRejecting(null);
const resultLabel = {
accept: "accepted",
start: "started",
complete: "completed",
reject: "rejected",
cancel: "cancelled"
}[action];
setMessage(`Work ${resultLabel}.`);
} 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}
id={`campaign-assignment-${assignment.id}`}
className={`campaign-work-item${assignment.id === requestedAssignmentId ? " is-focused" : ""}`}
tabIndex={-1}
>
<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, "accept")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
<Play size={16} aria-hidden="true" /> Accept
</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}
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
<span className="campaign-work-destructive-action">
<Button variant="danger" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
Reject work
</Button>
</span>
) : 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(rejecting)}
title="Reject assigned work?"
message="The work will close as rejected, separately from cancellation. Its purpose, assignee and transition history remain durable evidence."
confirmLabel="Reject work"
tone="danger"
busy={Boolean(busyId)}
onCancel={() => setRejecting(null)}
onConfirm={() => rejecting ? void transition(rejecting, "reject") : undefined}
/>
<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.";
}