create-safest-tools 0.6.0 → 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 +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- 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 +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- 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 +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- 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/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -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 +23 -16
- 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/0001_safest_resolve_baseline.sql +0 -1
- 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 +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- 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 +599 -83
- 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 +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- 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/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- 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-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -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 +61 -13
- 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
|
@@ -1,15 +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";
|
|
12
|
+
import { EnrichmentFindings } from "./EnrichmentFindings";
|
|
4
13
|
import {
|
|
5
|
-
|
|
14
|
+
reportEvents,
|
|
15
|
+
dispatchOpenReport,
|
|
6
16
|
dispatchCloseReport,
|
|
7
17
|
dispatchHumanDecision,
|
|
8
|
-
dispatchReleaseReport,
|
|
9
18
|
dispatchReporterMessage,
|
|
10
19
|
} from "./events";
|
|
20
|
+
import { otherReviewersOnReport, reportPresenceLabel } from "./presence-presentation";
|
|
11
21
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
12
|
-
import type { ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
|
|
22
|
+
import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, ReportDestination, TimelineEvent } from "./types";
|
|
13
23
|
|
|
14
24
|
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
|
15
25
|
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
@@ -19,6 +29,15 @@ function EmptyLine({ children }: { children: React.ReactNode }) {
|
|
|
19
29
|
return <p className="detail-empty">{children}</p>;
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
function ReportCollaborators({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
33
|
+
if (!analysts.length) return null;
|
|
34
|
+
const label = reportPresenceLabel(analysts);
|
|
35
|
+
return <div className="report-collaborators" aria-label={label}>
|
|
36
|
+
<span className="presence-avatar-stack" aria-hidden="true">{analysts.slice(0, 3).map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}{analysts.length > 3 ? <span className="presence-overflow-avatar">+{analysts.length - 3}</span> : null}</span>
|
|
37
|
+
<span>{label}</span>
|
|
38
|
+
</div>;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
function answerOptionLabel(answer: ReportAnswer, value: string): string {
|
|
23
42
|
return answer.options?.find((option) => option.value === value)?.label || readable(value);
|
|
24
43
|
}
|
|
@@ -103,22 +122,24 @@ const decisionOptions = [
|
|
|
103
122
|
{ value: "duplicate", label: "Duplicate report" },
|
|
104
123
|
] as const;
|
|
105
124
|
|
|
106
|
-
function DetailContent({ report, actorId,
|
|
125
|
+
function DetailContent({ report, actorId, actionCodes, messageReporters, decideReports, readEvidence, pendingAction, messageMutationVersion, reviewRequest, destination }: {
|
|
126
|
+
reviewRequest: number;
|
|
127
|
+
destination?: ReportDestination;
|
|
107
128
|
report: ReportDetailRecord;
|
|
108
129
|
actorId: string;
|
|
109
|
-
generatedAt: string;
|
|
110
130
|
actionCodes: string[];
|
|
111
|
-
claimReports: boolean;
|
|
112
131
|
messageReporters: boolean;
|
|
113
132
|
decideReports: boolean;
|
|
114
|
-
|
|
133
|
+
readEvidence: boolean;
|
|
134
|
+
pendingAction: "claim" | "release" | "transfer" | "message" | "decision" | null;
|
|
115
135
|
messageMutationVersion: number;
|
|
116
|
-
statusMessage: string;
|
|
117
|
-
error: string;
|
|
118
136
|
}) {
|
|
119
137
|
const evidence = report.evidence ?? [];
|
|
120
138
|
const answers = report.answers ?? [];
|
|
121
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 || {});
|
|
122
143
|
const workflowRuns = report.workflowRuns ?? [];
|
|
123
144
|
const timeline = report.timeline ?? [];
|
|
124
145
|
const caseTimeline = timeline.filter((item) => {
|
|
@@ -133,41 +154,55 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
133
154
|
const decisions = report.decisions ?? [];
|
|
134
155
|
const aiRuns = report.aiRuns ?? [];
|
|
135
156
|
const rules = report.policyRules ?? [];
|
|
136
|
-
const [
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const [
|
|
143
|
-
const [
|
|
144
|
-
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 ?? (""));
|
|
145
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);
|
|
146
188
|
useEffect(() => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
setActionCode("");
|
|
156
|
-
}, [report.id]);
|
|
157
|
-
useEffect(() => setMessage(""), [messageMutationVersion]);
|
|
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]);
|
|
191
|
+
useEffect(() => {
|
|
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
|
+
|
|
158
197
|
|
|
159
198
|
const deliveries = useMemo(() => [
|
|
160
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)}` : ""}` })),
|
|
161
|
-
...(report.notices ?? []).map((notice) => ({ id: `notice-${notice.id ?? notice.audience}`, title: `${readable(notice.audience)} notice`, state: notice.deliveryState, detail: `Updated ${relativeTime(notice.updatedAt)}` })),
|
|
162
|
-
...(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)}` : ""}` })),
|
|
163
200
|
], [report]);
|
|
164
201
|
const customerUrl = safeCustomerUrl(report.customerUrl);
|
|
165
|
-
const activeClaim =
|
|
202
|
+
const activeClaim = useEditingAccess(report.claim?.expiresAt);
|
|
166
203
|
const mine = Boolean(activeClaim && report.claim?.reviewerId === actorId);
|
|
167
|
-
const claimable = claimReports && ["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state);
|
|
168
204
|
const canMessage = mine && messageReporters;
|
|
169
205
|
const canDecide = mine && decideReports && rules.length > 0;
|
|
170
|
-
const changingClaim = pendingAction === "claim" || pendingAction === "release";
|
|
171
206
|
|
|
172
207
|
const sendMessage = (event: FormEvent<HTMLFormElement>): void => {
|
|
173
208
|
event.preventDefault();
|
|
@@ -178,10 +213,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
178
213
|
dispatchHumanDecision({ decisionCode, policyCode, rationale, evidenceReferences: [...selectedEvidence], reporterNotice, affectedUserNotice: affectedNotice, actionCode });
|
|
179
214
|
};
|
|
180
215
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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">
|
|
185
224
|
<Fact label="Form">{report.formTitle || report.formVersionId || "Unknown form"}</Fact>
|
|
186
225
|
<Fact label="Channel">{readable(report.source || "unknown")}</Fact>
|
|
187
226
|
<Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
|
|
@@ -197,48 +236,64 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
197
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>
|
|
198
237
|
<Fact label="Owner reference">{report.ownerReference || "—"}</Fact>
|
|
199
238
|
<Fact label="Reporter reference">{report.reporterReference || "—"}</Fact>
|
|
200
|
-
<Fact label="Adjudication policy">{report.policyTitle || "Policy
|
|
201
|
-
<Fact label="Queue policy version">{report.queuePolicyVersionId || "—"}</Fact>
|
|
239
|
+
<Fact label="Adjudication policy">{report.policyTitle || "Policy unavailable"}</Fact>
|
|
202
240
|
<Fact label="Reviewer policy">{report.reviewerInstructions || "—"}</Fact>
|
|
203
241
|
<Fact label="Received">{dateTime(report.receivedAt)}</Fact>
|
|
204
|
-
</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">
|
|
205
244
|
<section><h3>Evidence references considered</h3><div className="evidence-list">
|
|
206
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>}
|
|
207
|
-
</div></section>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{findings.length ? <section><h3>Component findings</h3><div className="finding-result-list">{findings.map((finding, index) => <article key={finding.id ?? `${finding.findingType}-${index}`}><div><strong>{finding.findingType}</strong><small>{finding.summary}{finding.componentVersionId ? ` · component ${finding.componentVersionId}` : ""}{finding.expiresAt ? ` · expires ${dateTime(finding.expiresAt)}` : ""}</small>{finding.data ? <pre>{JSON.stringify(finding.data, null, 2)}</pre> : null}</div><span className={`badge${finding.freshness !== "available" ? " urgent" : ""}`}>{readable(finding.freshness || finding.status)}</span></article>)}</div></section> : null}
|
|
215
|
-
|
|
216
|
-
{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}
|
|
217
|
-
|
|
218
|
-
{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 <>
|
|
219
253
|
|
|
220
|
-
|
|
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>)}
|
|
221
264
|
|
|
222
|
-
|
|
265
|
+
</>;
|
|
223
266
|
|
|
224
|
-
<div className="detail-actions">
|
|
225
|
-
<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>
|
|
226
|
-
<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>
|
|
227
|
-
</div>
|
|
228
|
-
<p className="mutation-status" role="status">{statusMessage}</p>
|
|
229
|
-
<p className="error" role="alert">{error}</p>
|
|
230
|
-
</>
|
|
231
|
-
);
|
|
232
267
|
}
|
|
233
268
|
|
|
234
269
|
export function ReportDrawer() {
|
|
235
270
|
const snapshot = useSyncExternalStore(subscribeToReportWorkspace, getReportWorkspaceSnapshot, getReportWorkspaceSnapshot);
|
|
236
271
|
const detail = snapshot.detail;
|
|
237
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]);
|
|
285
|
+
const collaborators = detail.report ? otherReviewersOnReport(snapshot.presence, detail.report.id, snapshot.actorId) : [];
|
|
238
286
|
return (
|
|
239
|
-
<dialog
|
|
240
|
-
|
|
241
|
-
|
|
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>
|
|
242
297
|
</dialog>
|
|
243
298
|
);
|
|
244
299
|
}
|
|
@@ -1,39 +1,124 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { priorityName } from "../lib/priority";
|
|
2
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type ChangeEvent, type MouseEvent } from "react";
|
|
3
|
+
import { AnalystAvatar, AnalystIdentity } from "../components/AnalystIdentity";
|
|
3
4
|
import { PageHeader } from "../components/PageHeader";
|
|
4
5
|
import { readable, relativeTime } from "../lib/format";
|
|
5
6
|
import { dispatchOpenReport, dispatchReportFilters, dispatchReportRefresh } from "./events";
|
|
7
|
+
import { orderedPresence, partitionPresence, presenceSummaryLabel, reviewersOnReport } from "./presence-presentation";
|
|
6
8
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
7
9
|
import type { PresenceAnalyst, ReportFilters, ReportPriorityFilter, ReportSort, ReportSummary } from "./types";
|
|
8
10
|
|
|
9
|
-
function
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
function PresenceGroup({ label, analysts }: { label: string; analysts: PresenceAnalyst[] }) {
|
|
12
|
+
if (!analysts.length) return null;
|
|
13
|
+
return (
|
|
14
|
+
<section className="presence-group" aria-label={label}>
|
|
15
|
+
<h3>{label}<span>{analysts.length}</span></h3>
|
|
16
|
+
<ul>{analysts.map((analyst) => (
|
|
17
|
+
<li key={analyst.actorId}>
|
|
18
|
+
<AnalystIdentity analyst={analyst} detail={analyst.activityLabel} />
|
|
19
|
+
<span className={`presence-dot ${analyst.activity}`} title={analyst.activityLabel} aria-hidden="true" />
|
|
20
|
+
</li>
|
|
21
|
+
))}</ul>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
function Presence({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const rootRef = useRef<HTMLElement>(null);
|
|
29
|
+
const summaryRef = useRef<HTMLButtonElement>(null);
|
|
30
|
+
const ordered = orderedPresence(analysts);
|
|
31
|
+
const groups = partitionPresence(ordered);
|
|
32
|
+
const summary = presenceSummaryLabel(ordered);
|
|
33
|
+
const visibleAvatars = ordered.slice(0, 3);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
const closeFromOutside = (event: PointerEvent): void => {
|
|
38
|
+
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
|
39
|
+
};
|
|
40
|
+
const closeFromKeyboard = (event: KeyboardEvent): void => {
|
|
41
|
+
if (event.key !== "Escape") return;
|
|
42
|
+
setOpen(false);
|
|
43
|
+
requestAnimationFrame(() => summaryRef.current?.focus());
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener("pointerdown", closeFromOutside);
|
|
46
|
+
document.addEventListener("keydown", closeFromKeyboard);
|
|
47
|
+
return () => {
|
|
48
|
+
document.removeEventListener("pointerdown", closeFromOutside);
|
|
49
|
+
document.removeEventListener("keydown", closeFromKeyboard);
|
|
50
|
+
};
|
|
51
|
+
}, [open]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!analysts.length) setOpen(false);
|
|
55
|
+
}, [analysts.length]);
|
|
56
|
+
|
|
57
|
+
const close = (): void => {
|
|
58
|
+
setOpen(false);
|
|
59
|
+
requestAnimationFrame(() => summaryRef.current?.focus());
|
|
60
|
+
};
|
|
61
|
+
|
|
16
62
|
return (
|
|
17
|
-
<section className=
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
63
|
+
<section className={`active-analysts${open ? " open" : ""}`} ref={rootRef} aria-label="Reviewer presence">
|
|
64
|
+
{ordered.length ? <button
|
|
65
|
+
className="presence-summary"
|
|
66
|
+
type="button"
|
|
67
|
+
ref={summaryRef}
|
|
68
|
+
aria-expanded={open}
|
|
69
|
+
aria-controls="reviewer-presence-panel"
|
|
70
|
+
aria-label={`${summary}. Show reviewer activity.`}
|
|
71
|
+
onClick={() => setOpen((current) => !current)}
|
|
72
|
+
>
|
|
73
|
+
<span className="presence-signal" aria-hidden="true" />
|
|
74
|
+
<strong>{summary}</strong>
|
|
75
|
+
<span className="presence-avatar-stack" aria-hidden="true">
|
|
76
|
+
{visibleAvatars.map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}
|
|
77
|
+
{ordered.length > visibleAvatars.length ? <span className="presence-overflow-avatar">+{ordered.length - visibleAvatars.length}</span> : null}
|
|
78
|
+
</span>
|
|
79
|
+
<span className="presence-chevron" aria-hidden="true" />
|
|
80
|
+
</button> : <div className="presence-summary presence-summary-empty"><span className="presence-signal" aria-hidden="true" /><strong>{summary}</strong></div>}
|
|
81
|
+
|
|
82
|
+
{open ? <>
|
|
83
|
+
<button className="presence-scrim" type="button" aria-label="Close reviewer activity" onClick={close} />
|
|
84
|
+
<div className="presence-panel" id="reviewer-presence-panel" role="dialog" aria-labelledby="reviewer-presence-title">
|
|
85
|
+
<span className="presence-sheet-handle" aria-hidden="true" />
|
|
86
|
+
<header><div><h2 id="reviewer-presence-title">{ordered.length} reviewer{ordered.length === 1 ? "" : "s"} online</h2><p>Live activity across the workspace.</p></div><button className="icon-button" type="button" aria-label="Close reviewer activity" onClick={close}>×</button></header>
|
|
87
|
+
<div className="presence-panel-body">
|
|
88
|
+
<PresenceGroup label="Reviewing reports" analysts={groups.reviewing} />
|
|
89
|
+
<PresenceGroup label="Elsewhere in the workspace" analysts={groups.elsewhere} />
|
|
24
90
|
</div>
|
|
25
|
-
|
|
26
|
-
|
|
91
|
+
</div>
|
|
92
|
+
</> : null}
|
|
27
93
|
</section>
|
|
28
94
|
);
|
|
29
95
|
}
|
|
30
96
|
|
|
31
97
|
function Owner({ report }: { report: ReportSummary }) {
|
|
32
|
-
if (!report.owner) return <span className="unclaimed">
|
|
33
|
-
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"} />;
|
|
34
100
|
}
|
|
35
101
|
|
|
36
|
-
function
|
|
102
|
+
function ReportViewers({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
103
|
+
if (!analysts.length) return null;
|
|
104
|
+
const visible = analysts.slice(0, 3);
|
|
105
|
+
const names = analysts.map((analyst) => analyst.displayName).join(", ");
|
|
106
|
+
const label = `${analysts.length} reviewer${analysts.length === 1 ? "" : "s"} viewing this report: ${names}`;
|
|
107
|
+
return (
|
|
108
|
+
<span className="report-row-viewers" role="img" aria-label={label} title={label}>
|
|
109
|
+
<span className="report-row-viewers-desktop" aria-hidden="true">
|
|
110
|
+
<span className="presence-avatar-stack">
|
|
111
|
+
{visible.map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}
|
|
112
|
+
{analysts.length > visible.length ? <span className="presence-overflow-avatar">+{analysts.length - visible.length}</span> : null}
|
|
113
|
+
</span>
|
|
114
|
+
<span>{analysts.length} viewing</span>
|
|
115
|
+
</span>
|
|
116
|
+
<span className="report-row-viewers-mobile" aria-hidden="true"><i />{analysts.length} viewing</span>
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ReportRow({ report, presence }: { report: ReportSummary; presence: PresenceAnalyst[] }) {
|
|
37
122
|
const open = (): void => dispatchOpenReport(report.id);
|
|
38
123
|
const openFromButton = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
39
124
|
event.stopPropagation();
|
|
@@ -42,7 +127,7 @@ function ReportRow({ report }: { report: ReportSummary }) {
|
|
|
42
127
|
const priority = priorityName(report.priority);
|
|
43
128
|
return (
|
|
44
129
|
<tr onClick={open}>
|
|
45
|
-
<td><div className="report-identity"><span className="report-avatar" aria-hidden="true">{String(report.targetReference || report.reference).slice(0, 2).toUpperCase()}</span><span><button className="report-link" type="button" onClick={openFromButton}>{report.reference}</button><small>{report.targetType}: {report.targetReference} · {readable(report.reasonCode)}</small></span></div></td>
|
|
130
|
+
<td><div className="report-identity"><span className="report-avatar" aria-hidden="true">{String(report.targetReference || report.reference).slice(0, 2).toUpperCase()}</span><span className="report-primary-copy"><button className="report-link" type="button" onClick={openFromButton}>{report.reference}</button><small>{report.targetType}: {report.targetReference} · {readable(report.reasonCode)}</small><span className="report-mobile-meta"><span>{report.queueName}</span><span>{readable(report.state)}</span><span className={`priority-badge ${priority}`}>{readable(priority)}</span></span><ReportViewers analysts={reviewersOnReport(presence, report.id)} /></span></div></td>
|
|
46
131
|
<td><strong>{report.queueName}</strong><small>{readable(report.state)}</small></td>
|
|
47
132
|
<td><span className={`priority-badge ${priority}`}>{readable(priority)}</span></td>
|
|
48
133
|
<td><strong>{relativeTime(report.receivedAt).replace(" ago", "")}</strong><small>{new Date(report.receivedAt).toLocaleString()}</small></td>
|
|
@@ -60,26 +145,30 @@ export function ReportWorkspace() {
|
|
|
60
145
|
|
|
61
146
|
return (
|
|
62
147
|
<div data-react-slice="report-workspace">
|
|
63
|
-
<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>}>
|
|
64
149
|
<Presence analysts={snapshot.presence} />
|
|
65
150
|
</PageHeader>
|
|
66
151
|
|
|
67
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>
|
|
68
156
|
<h2 id="inbox-title" className="visually-hidden">Accepted reports</h2>
|
|
69
157
|
<div className="surface-head">
|
|
70
158
|
<div className="queue-toolbar">
|
|
71
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>
|
|
72
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>
|
|
73
|
-
<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>
|
|
74
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>
|
|
75
|
-
<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>
|
|
76
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>
|
|
77
166
|
</div>
|
|
78
167
|
<button className="icon-button refresh-button" type="button" aria-label="Refresh reports" disabled={snapshot.refreshing} onClick={dispatchReportRefresh}>{snapshot.refreshing ? "…" : "↻"}</button>
|
|
79
168
|
</div>
|
|
80
169
|
<div className="table-wrap" aria-busy={snapshot.loading || snapshot.refreshing}>
|
|
81
170
|
<table><thead><tr><th>Report</th><th>Queue</th><th>Priority</th><th>Age</th><th>Owner</th></tr></thead><tbody>
|
|
82
|
-
{snapshot.loading ? <tr><td colSpan={5}><div className="skeleton-block" /></td></tr> : snapshot.reports.map((report) => <ReportRow report={report} key={report.id} />)}
|
|
171
|
+
{snapshot.loading ? <tr><td colSpan={5}><div className="skeleton-block" /></td></tr> : snapshot.reports.map((report) => <ReportRow report={report} presence={snapshot.presence} key={report.id} />)}
|
|
83
172
|
</tbody></table>
|
|
84
173
|
{!snapshot.loading && snapshot.reports.length === 0 ? <div className="empty"><span aria-hidden="true">✓</span><h3>No reports in this view</h3><p>New reports will appear here after intake and routing.</p></div> : null}
|
|
85
174
|
</div>
|