feat: orchestrate accountable Campaign work
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 02:14:35 +02:00
parent 4f52f010ee
commit 1bd24f9b5b
17 changed files with 1605 additions and 26 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+2 -2
View File
@@ -82,7 +82,7 @@ export type CampaignCollaborationCreate = {
};
export type CampaignWorkAssigneeType = "account" | "group" | "organization_function";
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "cancelled";
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "rejected" | "cancelled";
export type CampaignWorkAssignmentResolutionState = "resolved" | "unavailable" | "provider_unavailable";
export type CampaignWorkAssignment = {
@@ -1976,7 +1976,7 @@ export async function transitionCampaignWorkAssignment(
settings: ApiSettings,
campaignId: string,
assignment: Pick<CampaignWorkAssignment, "id" | "resource_revision">,
action: "start" | "complete" | "cancel"
action: "accept" | "start" | "complete" | "reject" | "cancel"
): Promise<CampaignWorkAssignment> {
return apiFetch<CampaignWorkAssignment>(
settings,
@@ -1,5 +1,6 @@
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,
@@ -59,6 +60,8 @@ export default function CampaignWorkPage({
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);
@@ -71,6 +74,7 @@ export default function CampaignWorkPage({
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);
@@ -102,6 +106,13 @@ export default function CampaignWorkPage({
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));
}
@@ -175,7 +186,7 @@ export default function CampaignWorkPage({
}
}
async function transition(assignment: CampaignWorkAssignment, action: "start" | "complete" | "cancel") {
async function transition(assignment: CampaignWorkAssignment, action: "accept" | "start" | "complete" | "reject" | "cancel") {
if (busyId) return;
setBusyId(assignment.id);
setError("");
@@ -183,7 +194,15 @@ export default function CampaignWorkPage({
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
replaceAssignment(updated);
setCancelling(null);
setMessage(`Work ${action === "start" ? "started" : action === "complete" ? "completed" : "cancelled"}.`);
setRejecting(null);
const resultLabel = {
accept: "accepted",
start: "started",
complete: "completed",
reject: "rejected",
cancel: "cancelled"
}[action];
setMessage(`Work ${resultLabel}.`);
} catch (err) {
setError(errorText(err));
} finally {
@@ -285,7 +304,12 @@ export default function CampaignWorkPage({
) : (
<ol className="campaign-work-list" aria-label="Campaign work assignments">
{assignments.map((assignment) => (
<li key={assignment.id} className="campaign-work-item">
<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>
@@ -319,8 +343,8 @@ export default function CampaignWorkPage({
<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 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") ? (
@@ -333,6 +357,13 @@ export default function CampaignWorkPage({
<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">
@@ -391,6 +422,17 @@ export default function CampaignWorkPage({
{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?"
+1
View File
@@ -2802,6 +2802,7 @@
.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.is-focused { box-shadow: var(--focus-ring-strong); }
.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); }