feat(campaigns): add accountable work assignments
Module Package Release / publish-packages (push) Successful in 14s
Module Package Release / publish-packages (push) Successful in 14s
This commit is contained in:
+3
-2
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user