create-safest-tools 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/template/README.md +1 -1
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +12 -6
- package/template/console/command/controller.ts +13 -9
- package/template/console/command/presentation.ts +1 -6
- package/template/console/command/store.ts +1 -0
- package/template/console/command/types.ts +1 -0
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +34 -4
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +109 -67
- package/template/console/reports/ReportWorkspace.tsx +11 -12
- package/template/console/reports/controller.ts +96 -49
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +41 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +22 -8
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +25 -17
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +18701 -11800
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +329 -10
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +3 -2
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/database-readiness.ts +8 -3
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +178 -18
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-delivery.ts +136 -129
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-operations.ts +16 -63
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +181 -274
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +18 -7
- package/template/src/workflow-effects.ts +95 -18
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +9 -4
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEditingAccess } from "./use-editing-access";
|
|
2
|
+
import { readPrivateDraft, savePrivateDraft, removePrivateDraft } from "./private-drafts";
|
|
3
|
+
import { useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
4
|
+
import { dateTime, readable } from "../lib/format";
|
|
5
|
+
import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
6
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
7
|
+
import { dispatchOpenReport, reportEvents } from "./events";
|
|
8
|
+
import type { ReportDetailRecord, ReportDestination } from "./types";
|
|
9
|
+
|
|
10
|
+
type Task = {
|
|
11
|
+
id: string; runId: string; state: string; taskType: string; assignedOperatorId: string | null;
|
|
12
|
+
claimExpiresAt: string | null; dueAt: string | null; actionProposalId: string | null;
|
|
13
|
+
context: Record<string, unknown>;
|
|
14
|
+
responseSchema: { properties?: { decision_code?: { enum?: string[] } } };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function ReviewTask({ task, report, actorId, canApprove, canDecide, onChanged }: {
|
|
18
|
+
task: Task; report: ReportDetailRecord; actorId: string; canApprove: boolean; canDecide: boolean; onChanged: (message: string) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const fieldId = useId();
|
|
21
|
+
const approval = Boolean(task.actionProposalId);
|
|
22
|
+
const options = task.responseSchema?.properties?.decision_code?.enum ?? [];
|
|
23
|
+
const [savedDraft] = useState(() => readPrivateDraft(actorId, `task:${task.id}`));
|
|
24
|
+
const [decision, setDecision] = useState(savedDraft?.taskDecision || "");
|
|
25
|
+
const [rationale, setRationale] = useState(savedDraft?.rationale || "");
|
|
26
|
+
const [evidence, setEvidence] = useState<string[]>(savedDraft?.evidence || []);
|
|
27
|
+
const [findings, setFindings] = useState<string[]>(savedDraft?.findings || []);
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
const [error, setError] = useState("");
|
|
30
|
+
const activeRequest = useRef(false);
|
|
31
|
+
const savedRequest = useRef<{ fingerprint: string; key: string } | null>(null);
|
|
32
|
+
const dirty = Boolean(decision || rationale || evidence.length || findings.length);
|
|
33
|
+
const activeAccess = useEditingAccess(task.claimExpiresAt);
|
|
34
|
+
const mine = task.state === "claimed" && task.assignedOperatorId === actorId && activeAccess;
|
|
35
|
+
const claimable = task.state === "open" || (task.state === "claimed" && !activeAccess);
|
|
36
|
+
const canSubmit = approval ? canApprove : canDecide;
|
|
37
|
+
const title = approval ? "Approve the customer action" : typeof task.context.title === "string" ? task.context.title : "Review the case";
|
|
38
|
+
const instructions = typeof task.context.instructions === "string" ? task.context.instructions : report.reviewerInstructions;
|
|
39
|
+
const toggle = (items: string[], value: string, checked: boolean) => checked ? [...new Set([...items, value])] : items.filter(item => item !== value);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
savePrivateDraft(actorId,`task:${task.id}`, { message:"",awaitReporter:false,decisionCode:"",policyCode:"",rationale,reporterNotice:"",affectedNotice:"",actionCode:"",evidence,taskDecision:decision,findings });
|
|
43
|
+
}, [actorId,task.id,rationale,evidence,decision,findings]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const leaving = (event: Event) => { if (activeRequest.current) event.preventDefault(); };
|
|
46
|
+
window.addEventListener(reportEvents.beforeClose, leaving);
|
|
47
|
+
return () => window.removeEventListener(reportEvents.beforeClose, leaving);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const claim = async (release = false) => {
|
|
51
|
+
if (activeRequest.current) return;
|
|
52
|
+
activeRequest.current = true; setPending(true); setError("");
|
|
53
|
+
try {
|
|
54
|
+
await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/claim`, { method: release ? "DELETE" : "POST", headers: mutationHeaders(false) });
|
|
55
|
+
onChanged(release ? "Task editing access released. Your private draft is saved in this tab." : "Task editing access is active. Choose an outcome and explain your reasoning.");
|
|
56
|
+
} catch (cause) { setError(errorMessage(cause, "The review claim could not be changed.")); }
|
|
57
|
+
finally { activeRequest.current = false; setPending(false); }
|
|
58
|
+
};
|
|
59
|
+
const submit = async (event: FormEvent) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (!mine || activeRequest.current || !decision || !rationale.trim()) return;
|
|
62
|
+
const response = approval ? { decision, reason: rationale.trim() } : { decision_code: decision, rationale: rationale.trim(), evidence_references: evidence, finding_ids: findings };
|
|
63
|
+
const fingerprint = JSON.stringify({ taskId: task.id, response });
|
|
64
|
+
if (savedRequest.current?.fingerprint !== fingerprint) savedRequest.current = { fingerprint, key: crypto.randomUUID() };
|
|
65
|
+
activeRequest.current = true; setPending(true); setError("");
|
|
66
|
+
try {
|
|
67
|
+
await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/respond`, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": savedRequest.current.key }, body: JSON.stringify({ response }) });
|
|
68
|
+
removePrivateDraft(actorId,`task:${task.id}`); setDecision(""); setRationale(""); setEvidence([]); setFindings([]); savedRequest.current = null;
|
|
69
|
+
onChanged(approval ? "Approval recorded. The playbook will verify the customer action before completing the outcome." : "Review submitted. The playbook is continuing from your saved response.");
|
|
70
|
+
} catch (cause) { setError(cause instanceof ApiRequestError ? cause.message : "The connection was interrupted. Your response is still here. Retry to check the same submission."); }
|
|
71
|
+
finally { activeRequest.current = false; setPending(false); }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return <article className="case-review-task" data-task-id={task.id} tabIndex={-1} aria-label={title}>
|
|
75
|
+
<div className="case-review-heading"><div><h4>{title}</h4>{task.dueAt ? <small>Due {dateTime(task.dueAt)}</small> : null}</div><span className="badge">{mine ? "Your review" : readable(task.state)}</span></div>
|
|
76
|
+
{instructions ? <p className="case-review-instructions">{instructions}</p> : null}
|
|
77
|
+
{approval ? <div className="case-action-preview"><strong>{readable(String(task.context.action_code || "Customer action"))}</strong><p>Review the exact target and payload before approving.</p><dl><dt>Target</dt><dd><pre tabIndex={0} aria-label="Proposed action target">{JSON.stringify(task.context.target, null, 2)}</pre></dd><dt>Action details</dt><dd><pre tabIndex={0} aria-label="Proposed action details">{JSON.stringify(task.context.payload, null, 2)}</pre></dd></dl></div> : null}
|
|
78
|
+
{mine ? <p className="form-help">Editing access lasts until {dateTime(task.claimExpiresAt || "")}. Your case ownership stays unchanged. <button type="button" className="text-button" disabled={pending} onClick={() => void claim(true)}>Release task access</button></p> : claimable ? <button className="primary" type="button" disabled={pending || !canSubmit} onClick={() => void claim()}>{pending ? "Starting…" : "Start task"}</button> : <p className="form-help">Another reviewer holds this task. You can continue reading the case.</p>}
|
|
79
|
+
{approval || options.length ? <form onSubmit={event => void submit(event)}><fieldset disabled={!mine || pending || !canSubmit}><legend>{approval ? "Your approval" : "Your review"}</legend>
|
|
80
|
+
<div className="case-review-field"><label htmlFor={`${fieldId}-outcome`}>{approval ? "Action approval" : "Review outcome"}</label><select id={`${fieldId}-outcome`} required value={decision} onChange={event => setDecision(event.currentTarget.value)}><option value="">Choose an outcome</option>{(approval ? ["approved", "rejected"] : options).map(code => <option value={code} key={code}>{readable(code)}</option>)}</select></div>
|
|
81
|
+
<div className="case-review-field"><label htmlFor={`${fieldId}-rationale`}>{approval ? "Approval reason" : "Review rationale"}</label><textarea id={`${fieldId}-rationale`} required maxLength={approval ? 1000 : 2000} value={rationale} onChange={event => setRationale(event.currentTarget.value)} placeholder="Explain what the evidence establishes and how the policy applies." /></div>
|
|
82
|
+
{!approval ? <div className="case-review-sources"><strong>Sources supporting this decision</strong><p className="form-help">Select only the evidence you considered. Missing information should remain explicit in your rationale.</p>{(report.evidence ?? []).map(item => <label className="checkbox" key={item.reference}><input type="checkbox" checked={evidence.includes(item.reference)} onChange={event => setEvidence(toggle(evidence, item.reference, event.currentTarget.checked))} /><span>{item.reference} · {readable(item.availability)}</span></label>)}{(report.findings ?? []).filter(item => item.id && item.runId === task.runId && item.status === "available").map(item => <label className="checkbox" key={item.id}><input type="checkbox" checked={findings.includes(item.id!)} onChange={event => setFindings(toggle(findings, item.id!, event.currentTarget.checked))} /><span>{item.summary} · {item.componentKey || "Case finding"}</span></label>)}{!report.evidence?.length && !report.findings?.length ? <p>No evidence sources have been recorded yet.</p> : null}</div> : null}
|
|
83
|
+
<button className="primary" type="submit">{pending ? "Submitting…" : approval ? "Submit action approval" : "Submit review and continue"}</button>
|
|
84
|
+
</fieldset></form> : <p className="error">This task uses a response form that is not supported in the case workspace yet. Ask an administrator to inspect the published task.</p>}
|
|
85
|
+
{!canSubmit ? <p className="form-help">{approval ? "Your role can read this task but cannot approve customer actions." : "Your role can read this task but cannot submit a case decision."}</p> : null}
|
|
86
|
+
{dirty ? <p className="private-draft-status">Private task draft saved in this tab.</p> : null}
|
|
87
|
+
{error ? <p className="error" role="alert">{error}</p> : null}
|
|
88
|
+
</article>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function CaseReviewTasks({ report, onOutcome, destination }: { report: ReportDetailRecord; onOutcome: () => void; destination?: ReportDestination }) {
|
|
92
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
93
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
94
|
+
const [loading, setLoading] = useState(true);
|
|
95
|
+
const [error, setError] = useState("");
|
|
96
|
+
const [notice, setNotice] = useState("");
|
|
97
|
+
const [epoch, setEpoch] = useState(0);
|
|
98
|
+
const sectionRef = useRef<HTMLElement>(null);
|
|
99
|
+
const focusedRequest = useRef(0);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (loading || destination?.section !== "review" || !destination.taskId || focusedRequest.current === destination.request) return;
|
|
102
|
+
const frame = requestAnimationFrame(() => {
|
|
103
|
+
const target = [...(sectionRef.current?.querySelectorAll<HTMLElement>("[data-task-id]") || [])]
|
|
104
|
+
.find(element => element.dataset.taskId === destination.taskId);
|
|
105
|
+
if (!target || !target.getClientRects().length) return;
|
|
106
|
+
focusedRequest.current = destination.request;
|
|
107
|
+
target.focus({ preventScroll: true });
|
|
108
|
+
const content = target.closest<HTMLElement>(".report-detail-content");
|
|
109
|
+
if (content) {
|
|
110
|
+
const tabs = content.querySelector(".case-section-tabs")?.getBoundingClientRect().height || 0;
|
|
111
|
+
content.scrollTop += target.getBoundingClientRect().top - content.getBoundingClientRect().top - tabs - 12;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return () => cancelAnimationFrame(frame);
|
|
115
|
+
}, [destination, loading, tasks]);
|
|
116
|
+
const canReview = shell.session?.permissions.reviewTasks === true;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!canReview) { setLoading(false); return; }
|
|
119
|
+
let active = true; let inFlight = false;
|
|
120
|
+
const refresh = async () => {
|
|
121
|
+
if (inFlight || document.visibilityState === "hidden") return;
|
|
122
|
+
inFlight = true;
|
|
123
|
+
try {
|
|
124
|
+
const result = await requestJson<{ tasks: Task[] }>(`/v1/admin/tasks?report_id=${encodeURIComponent(report.id)}&limit=100`);
|
|
125
|
+
if (active) { setTasks(result.tasks); setError(""); if (!result.tasks.some(task => ["open", "claimed"].includes(task.state))) dispatchOpenReport(report.id); }
|
|
126
|
+
} catch (cause) { if (active) setError(errorMessage(cause, "Review tasks could not be loaded.")); }
|
|
127
|
+
finally { inFlight = false; if (active) setLoading(false); }
|
|
128
|
+
};
|
|
129
|
+
void refresh(); const timer = setInterval(() => void refresh(), 5000);
|
|
130
|
+
return () => { active = false; clearInterval(timer); };
|
|
131
|
+
}, [report.id, canReview, epoch]);
|
|
132
|
+
const changed = (message: string) => { setNotice(message); setEpoch(value => value + 1); dispatchOpenReport(report.id); };
|
|
133
|
+
const open = tasks.filter(task => ["open", "claimed"].includes(task.state));
|
|
134
|
+
const actions = (report.workflowActions || []).filter(action => action.recordKind === "proposal");
|
|
135
|
+
const unresolved = actions.some(action => action.state !== "applied");
|
|
136
|
+
const failed = actions.some(action => action.state === "failed" && action.terminal);
|
|
137
|
+
const waitingNotices = (report.notices || []).some(notice => notice.decisionId && notice.deliveryState !== "delivered");
|
|
138
|
+
const terminal = report.workflowRuns?.some(run => run.runKind === "primary" && ["succeeded","failed","terminated","repair_required"].includes(run.state));
|
|
139
|
+
const completed = ["resolved_by_human","resolved_by_ai","closed","appealed"].includes(report.state);
|
|
140
|
+
return <section ref={sectionRef} className="case-next-step" aria-label="Playbook next step"><div className="case-review-heading"><div><h3>Next step</h3><p>This case follows its published playbook. Complete the current task to continue.</p></div><button className="secondary" type="button" onClick={() => { setEpoch(value => value + 1); dispatchOpenReport(report.id); }}>Refresh progress</button></div>
|
|
141
|
+
{!canReview ? <p>Your role can read this case. A reviewer with access to its queue must complete the next task.</p> : loading ? <p role="status">Loading the current review…</p> : open.length ? open.map(task => <ReviewTask key={task.id} task={task} report={report} actorId={shell.session!.actor.id} canApprove={shell.session?.permissions.approveActions === true} canDecide={shell.session?.permissions.decideReports === true} onChanged={changed} />) : completed ? <p>The review is recorded. <button type="button" className="text-button" onClick={onOutcome}>Inspect the outcome</button></p>
|
|
142
|
+
: unresolved ? <p>{failed ? "A customer action needs recovery. The case remains open." : "The playbook is waiting for customer action confirmation."} <button type="button" className="text-button" onClick={onOutcome}>Review customer actions</button></p>
|
|
143
|
+
: waitingNotices ? <p>Participant notice delivery is still outstanding. <button type="button" className="text-button" onClick={onOutcome}>Review participant notices</button></p>
|
|
144
|
+
: terminal ? <p>The playbook stopped without a completed outcome. Review its outcome and delivery history before continuing. <button type="button" className="text-button" onClick={onOutcome}>Inspect unresolved work</button></p>
|
|
145
|
+
: <p>The playbook is continuing. Its next review or approval will appear here.</p>}
|
|
146
|
+
{notice ? <p role="status">{notice}</p> : null}{error ? <p className="error" role="alert">{error} Use Refresh progress to try again.</p> : null}
|
|
147
|
+
</section>;
|
|
148
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readable } from "../lib/format";
|
|
2
|
+
import type { FindingRecord } from "./types";
|
|
3
|
+
const strings=(value:unknown):string[]=>Array.isArray(value)?value.filter((item):item is string=>typeof item==="string"):[];
|
|
4
|
+
const object=(value:unknown):Record<string,unknown>=>value && typeof value==="object" && !Array.isArray(value)?value as Record<string,unknown>:{};
|
|
5
|
+
function gapLabel(value:string) {
|
|
6
|
+
if(value.includes("budget"))return "The configured investigation budget was reached.";
|
|
7
|
+
if(value.includes("step_limit") || value.includes("proposal_missing"))return "The investigation reached its limit without a complete proposal.";
|
|
8
|
+
if(value.includes("time_limit"))return "The investigation timed out before it could complete.";
|
|
9
|
+
if(value.includes("tool_denied"))return "A requested tool was outside this playbook’s permitted scope.";
|
|
10
|
+
if(value.startsWith("investigation_") || value.startsWith("agent_"))return "The investigation could not validate a complete proposal. Review the available evidence directly.";
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function InvestigationBrief({findings,onEvidence}:{findings:FindingRecord[];onEvidence?:()=>void}) {
|
|
14
|
+
const investigation=[...findings].reverse().find(finding=>finding.findingType==="case-investigation");
|
|
15
|
+
if(!investigation)return null;
|
|
16
|
+
const data=object(investigation.data),execution=object(data.investigation);
|
|
17
|
+
const gaps=strings(data.missing_information),uncertainty=strings(data.uncertainty),citations=strings(data.citations);
|
|
18
|
+
const sources=Array.isArray(execution.sources)?execution.sources.map(object):[];
|
|
19
|
+
return <section className="investigation-brief" aria-label="Investigation brief">
|
|
20
|
+
<div className="investigation-brief-heading"><h3>Investigation brief</h3><span className="badge">{data.result_code==="needs_information"?"Needs more information":"Ready for review"}</span></div>
|
|
21
|
+
<p>{typeof data.summary==="string"?data.summary:investigation.summary}</p>
|
|
22
|
+
<p className="form-help">This is a proposal. Check the cited sources and apply your policy before deciding.</p>
|
|
23
|
+
{execution.fixture===true?<p className="investigation-fixture">Local test model — this result validates workflow behavior only.</p>:null}
|
|
24
|
+
{gaps.length?<div><h4>Missing information</h4><ul>{gaps.map((gap,index)=><li key={index}>{gapLabel(gap)}</li>)}</ul></div>:null}
|
|
25
|
+
{uncertainty.length?<div><h4>Uncertainty</h4><ul>{uncertainty.map((item,index)=><li key={index}>{gapLabel(item)}</li>)}</ul></div>:null}
|
|
26
|
+
<div><h4>Sources considered</h4>{citations.length?<ul>{citations.map(id=>{const finding=findings.find(f=>f.id===id);const source=sources.find(s=>s.id===id);return <li key={id}>{finding?.summary || (typeof source?.label==="string"?readable(source.label):"Case intake")}<small>{id}</small></li>;})}</ul>:<p>No source was validated for this proposal.</p>}{onEvidence && citations.length?<button className="secondary" type="button" onClick={onEvidence}>Review cited evidence</button>:null}</div>
|
|
27
|
+
<details><summary>Investigation activity</summary><p>{Number(execution.steps)||0} model steps · {Number(execution.tool_calls)||0} tool calls</p>{Array.isArray(execution.trace)?<ol>{execution.trace.map(object).map((entry,index)=><li key={index}>{readable(String(entry.tool))} · {readable(String(entry.status))}</li>)}</ol>:null}<p>{typeof execution.provider==="string"?readable(execution.provider):"Configured provider"} · {String(execution.model||"Configured model")}</p></details>
|
|
28
|
+
</section>;
|
|
29
|
+
}
|
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEditingAccess } from "./use-editing-access";
|
|
2
|
+
import { CaseOwnership } from "./CaseOwnership";
|
|
3
|
+
import { readPrivateDraft, savePrivateDraft } from "./private-drafts";
|
|
4
|
+
import { CaseNotices } from "./CaseNotices";
|
|
5
|
+
import { CaseAppeals, RelatedReports } from "./CaseContinuity";
|
|
6
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
2
7
|
import { AnalystAvatar } from "../components/AnalystIdentity";
|
|
3
8
|
import { dateTime, readable, relativeTime, safeCustomerUrl } from "../lib/format";
|
|
9
|
+
import { CaseActions } from "./CaseActions";
|
|
10
|
+
import { CaseReviewTasks } from "./CaseReviewTasks";
|
|
11
|
+
import { InvestigationBrief } from "./InvestigationBrief";
|
|
4
12
|
import { EnrichmentFindings } from "./EnrichmentFindings";
|
|
5
13
|
import {
|
|
6
|
-
|
|
14
|
+
reportEvents,
|
|
15
|
+
dispatchOpenReport,
|
|
7
16
|
dispatchCloseReport,
|
|
8
17
|
dispatchHumanDecision,
|
|
9
|
-
dispatchReleaseReport,
|
|
10
18
|
dispatchReporterMessage,
|
|
11
19
|
} from "./events";
|
|
12
20
|
import { otherReviewersOnReport, reportPresenceLabel } from "./presence-presentation";
|
|
13
21
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
14
|
-
import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
|
|
22
|
+
import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, ReportDestination, TimelineEvent } from "./types";
|
|
15
23
|
|
|
16
24
|
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
|
17
25
|
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
@@ -114,23 +122,24 @@ const decisionOptions = [
|
|
|
114
122
|
{ value: "duplicate", label: "Duplicate report" },
|
|
115
123
|
] as const;
|
|
116
124
|
|
|
117
|
-
function DetailContent({ report, actorId,
|
|
125
|
+
function DetailContent({ report, actorId, actionCodes, messageReporters, decideReports, readEvidence, pendingAction, messageMutationVersion, reviewRequest, destination }: {
|
|
126
|
+
reviewRequest: number;
|
|
127
|
+
destination?: ReportDestination;
|
|
118
128
|
report: ReportDetailRecord;
|
|
119
129
|
actorId: string;
|
|
120
|
-
generatedAt: string;
|
|
121
130
|
actionCodes: string[];
|
|
122
|
-
claimReports: boolean;
|
|
123
131
|
messageReporters: boolean;
|
|
124
132
|
decideReports: boolean;
|
|
125
133
|
readEvidence: boolean;
|
|
126
|
-
pendingAction: "claim" | "release" | "message" | "decision" | null;
|
|
134
|
+
pendingAction: "claim" | "release" | "transfer" | "message" | "decision" | null;
|
|
127
135
|
messageMutationVersion: number;
|
|
128
|
-
statusMessage: string;
|
|
129
|
-
error: string;
|
|
130
136
|
}) {
|
|
131
137
|
const evidence = report.evidence ?? [];
|
|
132
138
|
const answers = report.answers ?? [];
|
|
133
139
|
const messages = report.messages ?? [];
|
|
140
|
+
const [audience, setAudience] = useState("reporter");
|
|
141
|
+
const conversation = messages.filter(item => (item.audience || "reporter") === audience);
|
|
142
|
+
const customerFacts = Object.entries(report.customerFacts || {});
|
|
134
143
|
const workflowRuns = report.workflowRuns ?? [];
|
|
135
144
|
const timeline = report.timeline ?? [];
|
|
136
145
|
const caseTimeline = timeline.filter((item) => {
|
|
@@ -145,41 +154,55 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
145
154
|
const decisions = report.decisions ?? [];
|
|
146
155
|
const aiRuns = report.aiRuns ?? [];
|
|
147
156
|
const rules = report.policyRules ?? [];
|
|
148
|
-
const [
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const [
|
|
155
|
-
const [
|
|
156
|
-
const [
|
|
157
|
+
const [caseTab, setCaseTab] = useState("overview");
|
|
158
|
+
const tabsRef = useRef<HTMLDivElement>(null);
|
|
159
|
+
const selectTab = (tab: string) => {
|
|
160
|
+
setCaseTab(tab);
|
|
161
|
+
requestAnimationFrame(() => tabsRef.current?.closest(".report-detail-content")?.scrollTo({ top: 0, behavior: "auto" }));
|
|
162
|
+
};
|
|
163
|
+
const [savedDraft] = useState(() => readPrivateDraft(actorId, report.id));
|
|
164
|
+
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(() => new Set(savedDraft?.evidence ?? evidence.map((item) => item.reference)));
|
|
165
|
+
const [message, setMessage] = useState(savedDraft?.message ?? (""));
|
|
166
|
+
const [awaitReporter, setAwaitReporter] = useState(savedDraft?.awaitReporter ?? (true));
|
|
167
|
+
const [decisionCode, setDecisionCode] = useState(savedDraft?.decisionCode ?? ("no_violation_found"));
|
|
168
|
+
const [policyCode, setPolicyCode] = useState(savedDraft?.policyCode ?? (rules[0]?.code ?? ""));
|
|
169
|
+
const [rationale, setRationale] = useState(savedDraft?.rationale ?? (""));
|
|
170
|
+
const [reporterNotice, setReporterNotice] = useState(savedDraft?.reporterNotice ?? (""));
|
|
171
|
+
const [affectedNotice, setAffectedNotice] = useState(savedDraft?.affectedNotice ?? (""));
|
|
172
|
+
const [actionCode, setActionCode] = useState(savedDraft?.actionCode ?? (""));
|
|
157
173
|
|
|
174
|
+
useEffect(() => { if (reviewRequest) selectTab("review"); }, [reviewRequest]);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!destination) return;
|
|
177
|
+
selectTab(destination.section);
|
|
178
|
+
const frame = requestAnimationFrame(() => {
|
|
179
|
+
tabsRef.current?.querySelector<HTMLButtonElement>(`#case-tab-${destination.section}`)?.focus({ preventScroll: true });
|
|
180
|
+
});
|
|
181
|
+
return () => cancelAnimationFrame(frame);
|
|
182
|
+
}, [destination]);
|
|
183
|
+
const sentVersion = useRef(messageMutationVersion);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (sentVersion.current !== messageMutationVersion) { setMessage(""); sentVersion.current = messageMutationVersion; }
|
|
186
|
+
}, [messageMutationVersion]);
|
|
187
|
+
const hasDraft = Boolean(message || rationale || reporterNotice || affectedNotice || actionCode);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
savePrivateDraft(actorId, report.id, { message,awaitReporter,decisionCode,policyCode,rationale,reporterNotice,affectedNotice,actionCode,evidence:[...selectedEvidence] });
|
|
190
|
+
}, [actorId,report.id,message,awaitReporter,decisionCode,policyCode,rationale,reporterNotice,affectedNotice,actionCode,selectedEvidence]);
|
|
158
191
|
useEffect(() => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
setRationale("");
|
|
165
|
-
setReporterNotice("");
|
|
166
|
-
setAffectedNotice("");
|
|
167
|
-
setActionCode("");
|
|
168
|
-
}, [report.id]);
|
|
169
|
-
useEffect(() => setMessage(""), [messageMutationVersion]);
|
|
192
|
+
const leaving = (event: Event) => { if (pendingAction) event.preventDefault(); };
|
|
193
|
+
window.addEventListener(reportEvents.beforeClose, leaving);
|
|
194
|
+
return () => window.removeEventListener(reportEvents.beforeClose, leaving);
|
|
195
|
+
}, [pendingAction]);
|
|
196
|
+
|
|
170
197
|
|
|
171
198
|
const deliveries = useMemo(() => [
|
|
172
199
|
...(report.actions ?? []).map((action) => ({ id: `action-${action.id ?? action.actionCode}`, title: `Application action · ${readable(action.actionCode)}`, state: action.status, detail: `${action.attemptCount} attempt${action.attemptCount === 1 ? "" : "s"}${action.errorCode ? ` · ${readable(action.errorCode)}` : ""}` })),
|
|
173
|
-
...(report.notices ?? []).map((notice) => ({ id: `notice-${notice.id ?? notice.audience}`, title: `${readable(notice.audience)} notice`, state: notice.deliveryState, detail: `Updated ${relativeTime(notice.updatedAt)}` })),
|
|
174
|
-
...(report.workflowActions ?? []).filter((action) => action.recordKind === "proposal").map((action) => ({ id: `workflow-${action.id ?? action.actionCode}`, title: `Workflow action · ${readable(action.actionCode)}`, state: action.state, detail: `${readable(action.authorityMode || action.recordKind || "proposal")} · ${action.attemptCount || 0} attempt${action.attemptCount === 1 ? "" : "s"}${action.errorCode ? ` · ${readable(action.errorCode)}` : ""}` })),
|
|
175
200
|
], [report]);
|
|
176
201
|
const customerUrl = safeCustomerUrl(report.customerUrl);
|
|
177
|
-
const activeClaim =
|
|
202
|
+
const activeClaim = useEditingAccess(report.claim?.expiresAt);
|
|
178
203
|
const mine = Boolean(activeClaim && report.claim?.reviewerId === actorId);
|
|
179
|
-
const claimable = claimReports && ["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state);
|
|
180
204
|
const canMessage = mine && messageReporters;
|
|
181
205
|
const canDecide = mine && decideReports && rules.length > 0;
|
|
182
|
-
const changingClaim = pendingAction === "claim" || pendingAction === "release";
|
|
183
206
|
|
|
184
207
|
const sendMessage = (event: FormEvent<HTMLFormElement>): void => {
|
|
185
208
|
event.preventDefault();
|
|
@@ -190,10 +213,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
190
213
|
dispatchHumanDecision({ decisionCode, policyCode, rationale, evidenceReferences: [...selectedEvidence], reporterNotice, affectedUserNotice: affectedNotice, actionCode });
|
|
191
214
|
};
|
|
192
215
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
216
|
+
const tabs = [["overview","Overview"],["evidence","Evidence"],["review","Review"],["messages","Messages"],["outcome","Outcome"],["activity","Activity"]];
|
|
217
|
+
const primaryRun = workflowRuns.find(run => run.runKind === "primary");
|
|
218
|
+
const missingFindings = findings.filter(finding => ["unavailable","invalid","stale"].includes(finding.status || "") || Boolean(finding.expiresAt && Date.parse(finding.expiresAt) < Date.now()));
|
|
219
|
+
const unconfirmedActions = (report.workflowActions || []).filter(action => action.recordKind === "proposal" && action.state !== "applied").length + (report.actions || []).filter(action => !["accepted","already_applied"].includes(action.status)).length;
|
|
220
|
+
const deliveryProblems = (report.notices || []).filter(notice => ["failed","suppressed"].includes(notice.deliveryState)).length;
|
|
221
|
+
const taskReview = report.playbookManaged || Boolean(destination?.taskId);
|
|
222
|
+
const panels: Record<string, React.ReactNode> = {
|
|
223
|
+
overview: <><div className="case-context-grid"><section className="report-submission"><h3>Reporter submission</h3><dl className="submission-meta">
|
|
197
224
|
<Fact label="Form">{report.formTitle || report.formVersionId || "Unknown form"}</Fact>
|
|
198
225
|
<Fact label="Channel">{readable(report.source || "unknown")}</Fact>
|
|
199
226
|
<Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
|
|
@@ -209,49 +236,64 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
209
236
|
<Fact label="Customer content">{customerUrl ? <a className="text-button" href={customerUrl} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">Open in customer application ↗</a> : "Not provided"}</Fact>
|
|
210
237
|
<Fact label="Owner reference">{report.ownerReference || "—"}</Fact>
|
|
211
238
|
<Fact label="Reporter reference">{report.reporterReference || "—"}</Fact>
|
|
212
|
-
<Fact label="Adjudication policy">{report.policyTitle || "Policy
|
|
213
|
-
<Fact label="Queue policy version">{report.queuePolicyVersionId || "—"}</Fact>
|
|
239
|
+
<Fact label="Adjudication policy">{report.policyTitle || "Policy unavailable"}</Fact>
|
|
214
240
|
<Fact label="Reviewer policy">{report.reviewerInstructions || "—"}</Fact>
|
|
215
241
|
<Fact label="Received">{dateTime(report.receivedAt)}</Fact>
|
|
216
|
-
</dl></section>
|
|
242
|
+
</dl></section></div><section className="case-guidance"><h3>Customer context</h3><p>Context supplied at intake is separate from the reporter’s account and retrieved evidence.</p>{customerFacts.length ? <dl className="case-customer-facts">{customerFacts.map(([key,value]) => <div key={key}><dt>{readable(key)}</dt><dd>{typeof value === "boolean" ? value ? "Yes" : "No" : typeof value === "object" ? JSON.stringify(value) : String(value ?? "Not provided")}</dd></div>)}</dl> : <p>No customer context is available in this view.</p>}</section><section className="case-guidance"><h3>What to do next</h3><p>{decisions.length ? unconfirmedActions ? "A decision is recorded. Customer action confirmation is still outstanding." : deliveryProblems ? "A decision is recorded. Check the participant deliveries that need attention." : "Review the recorded outcome and its delivery history." : missingFindings.length ? "Some context is missing or expired. Review the evidence gaps before deciding." : "Read the allegation and available evidence, then complete the playbook’s next step."}</p><button type="button" className="primary" onClick={() => selectTab(decisions.length ? "outcome" : "evidence")}>{decisions.length ? "Inspect outcome" : "Review evidence"}</button></section><RelatedReports report={report} /></>,
|
|
243
|
+
evidence: <><div className="detail-grid">
|
|
217
244
|
<section><h3>Evidence references considered</h3><div className="evidence-list">
|
|
218
245
|
{evidence.length ? evidence.map((item) => <label className="evidence-item checkbox" key={item.reference}><input type="checkbox" checked={selectedEvidence.has(item.reference)} onChange={(event) => setSelectedEvidence((current) => { const next = new Set(current); if (event.currentTarget.checked) next.add(item.reference); else next.delete(item.reference); return next; })} /><span>{item.reference} · {readable(item.kind)} · {readable(item.availability)}</span></label>) : <EmptyLine>No evidence references were recorded.</EmptyLine>}
|
|
219
|
-
</div></section>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
|
|
227
|
-
|
|
228
|
-
{decisions.length ? <section><h3>Decisions & rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}
|
|
229
|
-
|
|
230
|
-
{deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}
|
|
246
|
+
</div></section></div><EnrichmentFindings findings={findings} workflowRuns={workflowRuns} permitted={readEvidence} /><button className="primary case-continue" type="button" onClick={() => selectTab("review")}>Continue to review</button></>,
|
|
247
|
+
review: <>{readEvidence ? <InvestigationBrief findings={findings} onEvidence={() => selectTab("evidence")} /> : null}{taskReview ? <CaseReviewTasks key={report.id} report={report} destination={destination} onOutcome={() => selectTab("outcome")} /> : null}<div className="detail-actions case-review-form">{!taskReview && ["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state) ? <form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision outcome<select value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required>{decisionOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || Boolean(pendingAction)}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form> : null}</div>{!taskReview && !["human_review","awaiting_reporter","human_review_requested"].includes(report.state) ? <section className="case-guidance"><h3>No decision is waiting here</h3><p>Check Outcome for the recorded decision and Activity for the case’s progress.</p></section> : null}</>,
|
|
248
|
+
messages: <><section><h3>Case messages</h3><p>Choose an audience to read that conversation.</p><div className="case-audience-picker" role="group" aria-label="Conversation audience">{[["reporter","Reporter"],["affected_user","Affected person"],["internal","Internal team"]].map(([key,label]) => <button className="secondary" type="button" aria-pressed={audience === key} key={key} onClick={() => setAudience(key!)}>{label}</button>)}</div><div className="message-list">{conversation.length ? conversation.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><strong className="message-audience">{item.audience === "affected_user" ? "Affected person" : item.audience === "internal" ? "Internal team" : "Reporter"}</strong><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No messages in this conversation yet.</EmptyLine>}</div></section><div className="detail-actions case-message-form">{audience === "reporter" ? <form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || Boolean(pendingAction)} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || Boolean(pendingAction)} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || Boolean(pendingAction)}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form> : <p>Outcome notices for this audience appear here when they are recorded. Participant delivery follows the case’s configured channels.</p>}</div></>,
|
|
249
|
+
outcome: <>{decisions.length ? <section><h3>Decisions & rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}{!decisions.length ? <section className="case-guidance"><h3>No decision recorded</h3><p>Complete the required review before proposing a customer action or sending an outcome.</p><button type="button" className="secondary" onClick={() => selectTab("review")}>Open review</button></section> : null}{(report.workflowActions || []).some(action => action.recordKind === "proposal") ? <CaseActions report={report} onReview={() => selectTab("review")} /> : null}<CaseAppeals report={report} />{report.notices?.length ? <CaseNotices report={report} /> : null}{deliveries.length ? <section><h3>Other application deliveries</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}{decisions.length && !deliveries.length && !report.notices?.length && !report.workflowActions?.length ? <p>No customer actions or participant notices have been recorded for this decision.</p> : null}</>,
|
|
250
|
+
activity: <>{report.handoffs?.length ? <section className="case-handoff-history"><h3>Team handoffs</h3>{report.handoffs.map(item => <article key={item.id}><strong>{item.toReviewerId ? "Case transferred" : "Returned to queue"}</strong><p>{item.note}</p><small>{item.authorName} · {dateTime(item.createdAt)}</small></article>)}</section> : null}{workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`} aria-hidden="true" /><div><strong>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null} {aiRuns.length ? <section><h3>Bounded AI record</h3><div className="ai-run-list">{aiRuns.map((run, index) => <article key={run.id ?? `${run.model}-${index}`}><div><strong>{readable(run.mode)} · {readable(run.status)}</strong><small>{run.model} · {run.promptVersion} · {run.configVersionId}</small></div><p>{run.summary || readable(run.errorCode || "No validated result")}</p><small>{readable(run.outcome || "no outcome")} · {run.policyCode || "no policy"} · {run.estimatedCostMicrousd || 0} µUSD{run.uncertain ? " · escalated for uncertainty" : ""}</small></article>)}</div></section> : null}{!timeline.length && !workflowRuns.length ? <p>No activity has been loaded for this case.</p> : null}</>,
|
|
251
|
+
};
|
|
252
|
+
return <>
|
|
231
253
|
|
|
232
|
-
|
|
254
|
+
{hasDraft ? <p className="private-draft-status" role="status">Private draft saved in this tab. It stays here if you return or transfer the case.</p> : null}
|
|
255
|
+
{report.handoffs?.[0] ? <details className="latest-handoff"><summary>Latest handoff · {report.handoffs[0].authorName}</summary><p>{report.handoffs[0].note}</p></details> : null}
|
|
256
|
+
{report.partialLoadErrors?.length ? <p className="error case-load-warning" role="alert">Some case information could not refresh: {report.partialLoadErrors.join(", ")}. Previously loaded information is shown where available. Use Refresh case to retry.</p> : null}
|
|
257
|
+
<div className="case-section-tabs" ref={tabsRef} role="tablist" aria-label="Case sections">{tabs.map(([key,label],index) => <button key={key} id={`case-tab-${key}`} role="tab" type="button" aria-selected={caseTab === key} aria-controls={`case-panel-${key}`} tabIndex={caseTab === key ? 0 : -1} onClick={() => selectTab(key!)} onKeyDown={event => {
|
|
258
|
+
const next = event.key === "ArrowRight" ? (index+1)%tabs.length : event.key === "ArrowLeft" ? (index+tabs.length-1)%tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length-1 : null;
|
|
259
|
+
if (next === null) return;
|
|
260
|
+
event.preventDefault(); selectTab(tabs[next]![0]!); tabsRef.current?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[next]?.focus({ preventScroll:true });
|
|
261
|
+
}}>{label}</button>)}</div>
|
|
262
|
+
{caseTab === "overview" ? <section className="case-summary" aria-label="Case summary"><div><span className="context-label">Reported allegation</span><h3>{readable(report.reasonCode)} · {readable(report.targetType)}</h3><p>{report.targetReference}</p></div><div className="case-summary-facts"><span>{evidence.length} evidence reference{evidence.length === 1 ? "" : "s"}</span><span>{findings.length} retrieved finding{findings.length === 1 ? "" : "s"}</span><span>{primaryRun ? `${primaryRun.workflowName || primaryRun.workflowKey || "Playbook"} · version ${primaryRun.workflowVersion || "pinned"}` : "Direct human review"}</span></div>{!decisions.length ? <button className="secondary" type="button" onClick={() => selectTab("review")}>Open next step</button> : <button className="secondary" type="button" onClick={() => selectTab("outcome")}>{unconfirmedActions ? "Check action confirmation" : "View outcome"}</button>}</section> : null}
|
|
263
|
+
{tabs.map(([key]) => <div className="case-section-panel" id={`case-panel-${key}`} role="tabpanel" aria-labelledby={`case-tab-${key}`} key={key} hidden={caseTab !== key} tabIndex={0}>{panels[key!]}</div>)}
|
|
233
264
|
|
|
234
|
-
|
|
265
|
+
</>;
|
|
235
266
|
|
|
236
|
-
<div className="detail-actions">
|
|
237
|
-
<form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form>
|
|
238
|
-
<form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision outcome<select value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{decisionOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || pendingAction === "decision"}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form>
|
|
239
|
-
</div>
|
|
240
|
-
<p className="mutation-status" role="status">{statusMessage}</p>
|
|
241
|
-
<p className="error" role="alert">{error}</p>
|
|
242
|
-
</>
|
|
243
|
-
);
|
|
244
267
|
}
|
|
245
268
|
|
|
246
269
|
export function ReportDrawer() {
|
|
247
270
|
const snapshot = useSyncExternalStore(subscribeToReportWorkspace, getReportWorkspaceSnapshot, getReportWorkspaceSnapshot);
|
|
248
271
|
const detail = snapshot.detail;
|
|
249
272
|
const open = Boolean(detail.reportId);
|
|
273
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (!open) return;
|
|
276
|
+
const previous = document.activeElement;
|
|
277
|
+
const background = [...document.querySelectorAll<HTMLElement>("main, #workspace-shell-root")].map(element => ({ element, inert:element.inert }));
|
|
278
|
+
for (const item of background) item.element.inert=true;
|
|
279
|
+
dialogRef.current?.querySelector<HTMLElement>("h2")?.focus({ preventScroll:true });
|
|
280
|
+
return () => {
|
|
281
|
+
for (const item of background) item.element.inert=item.inert;
|
|
282
|
+
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll:true });
|
|
283
|
+
};
|
|
284
|
+
}, [open]);
|
|
250
285
|
const collaborators = detail.report ? otherReviewersOnReport(snapshot.presence, detail.report.id, snapshot.actorId) : [];
|
|
251
286
|
return (
|
|
252
|
-
<dialog
|
|
253
|
-
|
|
254
|
-
|
|
287
|
+
<dialog ref={dialogRef} onKeyDown={event => {
|
|
288
|
+
if (event.key !== "Tab" || (event.target instanceof Element && event.target.closest(".case-handoff-dialog"))) return;
|
|
289
|
+
const controls = [...(dialogRef.current?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]') || [])].filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0);
|
|
290
|
+
const first=controls[0], last=controls.at(-1);
|
|
291
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement?.tagName === "H2")) { event.preventDefault(); last?.focus(); }
|
|
292
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
293
|
+
}} className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" aria-modal={open ? true : undefined} onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
|
|
294
|
+
<div className="detail-head"><div><h2 id="report-detail-title" tabIndex={-1}>{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${(snapshot.queues.find(queue => queue.id === detail.report!.queueId)?.name || readable(detail.report.queueId || "unassigned"))}` : "Loading report…"}</p><ReportCollaborators analysts={collaborators} /></div><div className="case-header-actions">{detail.reportId ? <button className="secondary case-refresh" type="button" aria-label="Refresh case" title="Refresh case" onClick={() => dispatchOpenReport(detail.reportId!)}><span aria-hidden="true">↻</span><span className="case-refresh-label">Refresh case</span></button> : null}<button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div></div>
|
|
295
|
+
{detail.report ? <CaseOwnership key={detail.report.id} snapshot={snapshot} /> : null}
|
|
296
|
+
<div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent key={detail.report.id} reviewRequest={detail.reviewRequest || 0} destination={detail.destination} report={detail.report} actorId={snapshot.actorId} actionCodes={snapshot.actionCodes} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} readEvidence={snapshot.permissions.readEvidence} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
|
|
255
297
|
</dialog>
|
|
256
298
|
);
|
|
257
299
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { priorityName } from "../lib/priority";
|
|
1
2
|
import { useEffect, useRef, useState, useSyncExternalStore, type ChangeEvent, type MouseEvent } from "react";
|
|
2
3
|
import { AnalystAvatar, AnalystIdentity } from "../components/AnalystIdentity";
|
|
3
4
|
import { PageHeader } from "../components/PageHeader";
|
|
@@ -7,12 +8,6 @@ import { orderedPresence, partitionPresence, presenceSummaryLabel, reviewersOnRe
|
|
|
7
8
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
8
9
|
import type { PresenceAnalyst, ReportFilters, ReportPriorityFilter, ReportSort, ReportSummary } from "./types";
|
|
9
10
|
|
|
10
|
-
function priorityName(priority: number): "high" | "medium" | "low" {
|
|
11
|
-
if (priority >= 70) return "high";
|
|
12
|
-
if (priority >= 40) return "medium";
|
|
13
|
-
return "low";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
function PresenceGroup({ label, analysts }: { label: string; analysts: PresenceAnalyst[] }) {
|
|
17
12
|
if (!analysts.length) return null;
|
|
18
13
|
return (
|
|
@@ -100,8 +95,8 @@ function Presence({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
|
100
95
|
}
|
|
101
96
|
|
|
102
97
|
function Owner({ report }: { report: ReportSummary }) {
|
|
103
|
-
if (!report.owner) return <span className="unclaimed">
|
|
104
|
-
return <AnalystIdentity analyst={report.owner} detail={report.owner.mine ? "You" : "
|
|
98
|
+
if (!report.owner) return <span className="unclaimed">Unassigned</span>;
|
|
99
|
+
return <AnalystIdentity analyst={report.owner} detail={report.owner.mine ? "You" : "Case owner"} />;
|
|
105
100
|
}
|
|
106
101
|
|
|
107
102
|
function ReportViewers({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
@@ -110,7 +105,7 @@ function ReportViewers({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
|
110
105
|
const names = analysts.map((analyst) => analyst.displayName).join(", ");
|
|
111
106
|
const label = `${analysts.length} reviewer${analysts.length === 1 ? "" : "s"} viewing this report: ${names}`;
|
|
112
107
|
return (
|
|
113
|
-
<span className="report-row-viewers" aria-label={label} title={label}>
|
|
108
|
+
<span className="report-row-viewers" role="img" aria-label={label} title={label}>
|
|
114
109
|
<span className="report-row-viewers-desktop" aria-hidden="true">
|
|
115
110
|
<span className="presence-avatar-stack">
|
|
116
111
|
{visible.map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}
|
|
@@ -150,19 +145,23 @@ export function ReportWorkspace() {
|
|
|
150
145
|
|
|
151
146
|
return (
|
|
152
147
|
<div data-react-slice="report-workspace">
|
|
153
|
-
<PageHeader title="
|
|
148
|
+
<PageHeader title="Cases" description="Understand what happened, review the evidence, and follow each outcome." accessory={<span className="live-label"><i />Live queue</span>}>
|
|
154
149
|
<Presence analysts={snapshot.presence} />
|
|
155
150
|
</PageHeader>
|
|
156
151
|
|
|
157
152
|
<section className="surface report-surface" aria-labelledby="inbox-title">
|
|
153
|
+
<div className="ownership-filters" role="group" aria-label="Case ownership">
|
|
154
|
+
{([["", "All"], ["mine", "My work"], ["unassigned", "Unassigned"]] as const).map(([value, label]) => <button type="button" key={value} aria-pressed={(snapshot.filters.ownership || "") === value} onClick={() => dispatchReportFilters({ ...snapshot.filters, ownership: value, state: value === "mine" ? "" : "needs_review" })}>{label}</button>)}
|
|
155
|
+
</div>
|
|
158
156
|
<h2 id="inbox-title" className="visually-hidden">Accepted reports</h2>
|
|
159
157
|
<div className="surface-head">
|
|
160
158
|
<div className="queue-toolbar">
|
|
161
159
|
<label className="search-field"><span aria-hidden="true">⌕</span><input type="search" placeholder="Search reports" aria-label="Search reports" value={snapshot.filters.search} onChange={search} /></label>
|
|
162
160
|
<label>Queue<select value={snapshot.filters.queueId} onChange={(event) => updateFilter("queueId", event.currentTarget.value)}><option value="">All queues</option>{snapshot.queues.map((queue) => <option value={queue.id} key={queue.id}>{queue.name}</option>)}</select></label>
|
|
163
|
-
<label>Priority<select value={snapshot.filters.priority} onChange={(event) => updateFilter("priority", event.currentTarget.value as ReportPriorityFilter)}><option value="">All priorities</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option></select></label>
|
|
161
|
+
<label>Priority<select value={snapshot.filters.priority} onChange={(event) => updateFilter("priority", event.currentTarget.value as ReportPriorityFilter)}><option value="">All priorities</option><option value="urgent">Urgent</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option></select></label>
|
|
164
162
|
<label>Age<select value={snapshot.filters.age} onChange={(event) => updateFilter("age", event.currentTarget.value as ReportFilters["age"])}><option value="">Any age</option><option value="hour">Under 1 hour</option><option value="day">Under 24 hours</option><option value="older">Older than 24 hours</option></select></label>
|
|
165
|
-
<label>State<select value={snapshot.filters.state} onChange={(event) => updateFilter("state", event.currentTarget.value)}><option value="">All states</option><option value="received">Received</option><option value="human_review">Human review</option><option value="awaiting_reporter">Awaiting reporter</option><option value="resolved_by_human">Resolved by human</option></select></label>
|
|
163
|
+
<label>State<select value={snapshot.filters.state} onChange={(event) => updateFilter("state", event.currentTarget.value)}><option value="">All states</option><option value="needs_review">Needs review</option><option value="received">Received</option><option value="human_review">Human review</option><option value="awaiting_reporter">Awaiting reporter</option><option value="resolved_by_human">Resolved by human</option><option value="resolved_by_ai">Resolved by AI</option><option value="human_review_requested">Human review requested</option><option value="appealed">Appealed</option><option value="closed">Closed</option></select></label>
|
|
164
|
+
<label>Owner<select value={snapshot.filters.ownership || ""} onChange={(event) => updateFilter("ownership", event.currentTarget.value as ReportFilters["ownership"])}><option value="">All owners</option><option value="unassigned">Unassigned</option><option value="mine">Assigned to me</option></select></label>
|
|
166
165
|
<label>Sort<select value={snapshot.filters.sort} onChange={(event) => updateFilter("sort", event.currentTarget.value as ReportSort)}><option value="oldest">Oldest first</option><option value="newest">Newest first</option><option value="priority">Priority first</option></select></label>
|
|
167
166
|
</div>
|
|
168
167
|
<button className="icon-button refresh-button" type="button" aria-label="Refresh reports" disabled={snapshot.refreshing} onClick={dispatchReportRefresh}>{snapshot.refreshing ? "…" : "↻"}</button>
|