feat: orchestrate accountable Campaign work
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user