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
|
@@ -4,6 +4,7 @@ import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
|
4
4
|
import { configurationActionPath, configurationDefault, configurationEndpoint, configurationKeySlug, configurationLabel } from "./api";
|
|
5
5
|
import { announceConfigurationChanged, configurationEvents } from "./events";
|
|
6
6
|
import { editableReportForm, FormBuilder, reportFormContent, type EditableReportForm } from "./FormBuilder";
|
|
7
|
+
import { MessageBuilder, PolicyBuilder, RoutingBuilder } from "./ConfigurationBuilders";
|
|
7
8
|
import type {
|
|
8
9
|
ConfigurationDetailResponse,
|
|
9
10
|
ConfigurationDraft,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
ConfigurationVersion,
|
|
16
17
|
JsonObject,
|
|
17
18
|
QueueListResponse,
|
|
19
|
+
QueueSummary,
|
|
18
20
|
} from "./types";
|
|
19
21
|
|
|
20
22
|
interface EditorRequest { type: ConfigurationType; key: string; exists: boolean }
|
|
@@ -51,10 +53,12 @@ export function ConfigurationDialog() {
|
|
|
51
53
|
const [published, setPublished] = useState<ConfigurationVersion | null>(null);
|
|
52
54
|
const [form, setForm] = useState<EditableReportForm>(() => editableReportForm(configurationDefault("form")));
|
|
53
55
|
const [contentJson, setContentJson] = useState("{}");
|
|
56
|
+
const [queues, setQueues] = useState<QueueSummary[]>([]);
|
|
54
57
|
const [baseline, setBaseline] = useState("");
|
|
55
58
|
const [validation, setValidation] = useState<ConfigurationValidation | null>(null);
|
|
56
59
|
const [validationMessage, setValidationMessage] = useState("");
|
|
57
60
|
const [loading, setLoading] = useState(false);
|
|
61
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
58
62
|
const [action, setAction] = useState<"" | "save" | "validate" | "publish">("");
|
|
59
63
|
const [error, setError] = useState("");
|
|
60
64
|
const busy = loading || Boolean(action);
|
|
@@ -65,7 +69,7 @@ export function ConfigurationDialog() {
|
|
|
65
69
|
catch { return null; }
|
|
66
70
|
}, [contentJson, form, visualForm]);
|
|
67
71
|
const serialized = currentContent ? JSON.stringify(currentContent) : "";
|
|
68
|
-
const dirty = Boolean(editor && !loading && (serialized !== baseline || (!keyLocked && key !== editor.key)));
|
|
72
|
+
const dirty = Boolean(editor && !loading && !loadFailed && (serialized !== baseline || (!keyLocked && key !== editor.key)));
|
|
69
73
|
|
|
70
74
|
const clear = (): void => {
|
|
71
75
|
setEditor(null); setKey(""); setKeyEdited(false); setKeyLocked(false); setDraft(null); setPublished(null);
|
|
@@ -90,16 +94,16 @@ export function ConfigurationDialog() {
|
|
|
90
94
|
useEffect(() => {
|
|
91
95
|
const dialog = dialogRef.current;
|
|
92
96
|
if (!dialog) return;
|
|
93
|
-
if (editor && !dialog.open) { dialog.showModal();
|
|
97
|
+
if (editor && !dialog.open) { dialog.showModal(); }
|
|
94
98
|
if (!editor && dialog.open) dialog.close();
|
|
95
99
|
}, [editor]);
|
|
96
100
|
useEffect(() => {
|
|
97
101
|
if (!editor) return;
|
|
98
102
|
let active = true;
|
|
99
103
|
const load = async (): Promise<void> => {
|
|
100
|
-
setLoading(true); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
|
|
104
|
+
setLoading(true); setLoadFailed(false); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
|
|
101
105
|
try {
|
|
102
|
-
const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues")
|
|
106
|
+
const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues") : { queues: [] };
|
|
103
107
|
const fallback = configurationDefault(editor.type, queueResponse.queues || []);
|
|
104
108
|
let content = fallback;
|
|
105
109
|
let nextDraft: ConfigurationDraft | null = null;
|
|
@@ -111,6 +115,7 @@ export function ConfigurationDialog() {
|
|
|
111
115
|
content = nextDraft?.content || nextPublished?.content || fallback;
|
|
112
116
|
}
|
|
113
117
|
if (!active) return;
|
|
118
|
+
setQueues(queueResponse.queues || []);
|
|
114
119
|
const resolvedKey = editor.type === "routing_rules" ? "default" : editor.key;
|
|
115
120
|
setKey(resolvedKey); setKeyEdited(Boolean(resolvedKey)); setKeyLocked(editor.type === "routing_rules" || editor.exists);
|
|
116
121
|
setDraft(nextDraft); setPublished(nextPublished); setContentJson(JSON.stringify(content, null, 2));
|
|
@@ -121,7 +126,7 @@ export function ConfigurationDialog() {
|
|
|
121
126
|
setValidationMessage(nextDraft.validation.valid ? "Stored check passed." : `${nextDraft.validation.errors?.length || 0} stored problems.`);
|
|
122
127
|
} else if (nextPublished) setValidationMessage(`Based on published version ${nextPublished.version}. Save to create an editable draft.`);
|
|
123
128
|
else setValidationMessage("Not saved yet.");
|
|
124
|
-
} catch (cause) { if (active) setError(errorMessage(cause, "The configuration draft could not be opened.")); }
|
|
129
|
+
} catch (cause) { if (active) { setLoadFailed(true); setError(errorMessage(cause, "The configuration draft could not be opened.")); } }
|
|
125
130
|
finally { if (active) setLoading(false); }
|
|
126
131
|
};
|
|
127
132
|
void load();
|
|
@@ -129,8 +134,9 @@ export function ConfigurationDialog() {
|
|
|
129
134
|
}, [editor]);
|
|
130
135
|
|
|
131
136
|
const save = async (): Promise<ConfigurationSaveResponse | null> => {
|
|
137
|
+
if (loadFailed || dialogRef.current?.querySelector("form")?.reportValidity() === false) return null;
|
|
132
138
|
if (!editor || !currentContent) { setError("Configuration JSON must be a valid object."); return null; }
|
|
133
|
-
const resolvedKey = type === "routing_rules" ? "default" : key
|
|
139
|
+
const resolvedKey = type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, "");
|
|
134
140
|
if (!resolvedKey) { setError("An internal name is required."); keyRef.current?.focus(); return null; }
|
|
135
141
|
if (draft && serialized === baseline) return { draftId: draft.id, revision: draft.revision, validation: draft.validation };
|
|
136
142
|
setAction("save"); setError("");
|
|
@@ -157,7 +163,7 @@ export function ConfigurationDialog() {
|
|
|
157
163
|
if (!saved || !editor) return null;
|
|
158
164
|
setAction("validate"); setError("");
|
|
159
165
|
try {
|
|
160
|
-
const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "validate"), { method: "POST" });
|
|
166
|
+
const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "validate"), { method: "POST" });
|
|
161
167
|
setValidation(result.validation); setValidationMessage(result.validation.valid ? "Ready to publish." : "Changes needed.");
|
|
162
168
|
setDraft((current) => current ? { ...current, validation: result.validation, validationStatus: result.validation.valid ? "valid" : "invalid" } : current);
|
|
163
169
|
return { validation: result.validation, saved };
|
|
@@ -174,7 +180,7 @@ export function ConfigurationDialog() {
|
|
|
174
180
|
if (!window.confirm("Publish this immutable configuration version for future reports and workflow runs?")) return;
|
|
175
181
|
setAction("publish"); setError("");
|
|
176
182
|
try {
|
|
177
|
-
const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
|
|
183
|
+
const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
|
|
178
184
|
setPublished({ id: result.versionId, version: result.version, content: currentContent || draft?.content || {}, publishedAt: new Date().toISOString() });
|
|
179
185
|
setValidationMessage(`Published version ${result.version}. Future reports and workflow runs can use it; historical work does not change.`);
|
|
180
186
|
announceConfigurationChanged();
|
|
@@ -186,14 +192,23 @@ export function ConfigurationDialog() {
|
|
|
186
192
|
if (!keyLocked && !keyEdited && next.title !== form.title) setKey(configurationKeySlug(next.title, ""));
|
|
187
193
|
setForm(next); setValidation(null); setValidationMessage("Unsaved changes.");
|
|
188
194
|
};
|
|
195
|
+
const updateContent = (next: JsonObject): void => {
|
|
196
|
+
if (type === "policy" && !keyLocked && !keyEdited && next.title !== currentContent?.title) setKey(String(next.title || ""));
|
|
197
|
+
setContentJson(JSON.stringify(next, null, 2)); setValidation(null); setValidationMessage("Unsaved changes.");
|
|
198
|
+
};
|
|
189
199
|
const title = `${editor?.exists ? "Edit" : "New"} ${configurationLabel(type).toLowerCase()}`;
|
|
190
|
-
const subtitle = loading ? "Loading
|
|
200
|
+
const subtitle = loading ? "Loading…" : draft ? "Draft · Changes are not live until published" : published ? `Published version ${published.version} · Create an updated version` : "Draft · Changes are not live until published";
|
|
191
201
|
return <dialog ref={dialogRef} className="editor-dialog configuration-dialog" aria-labelledby="configuration-dialog-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="configuration-dialog">
|
|
192
|
-
<div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
|
|
193
|
-
{loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
|
|
194
|
-
<
|
|
195
|
-
{
|
|
202
|
+
<div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" autoFocus type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
|
|
203
|
+
{loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : loadFailed ? <div className="configuration-fields"><p className="error" role="alert">{error}</p><button className="secondary" type="button" onClick={() => setEditor(current => current ? { ...current } : current)}>Try again</button></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
|
|
204
|
+
<div className="configuration-fields">
|
|
205
|
+
{!keyLocked ? <label>Configuration name<small>A name your team will recognise.</small><input ref={keyRef} required disabled={busy} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label> : null}
|
|
206
|
+
{visualForm ? <FormBuilder value={form} onChange={updateForm} disabled={busy} /> : <>
|
|
207
|
+
{currentContent ? type === "routing_rules" ? <RoutingBuilder value={currentContent} onChange={updateContent} disabled={busy} queues={queues} /> : type === "policy" ? <PolicyBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <MessageBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <p className="error" role="alert">Correct the advanced configuration to continue editing.</p>}
|
|
208
|
+
<details className="configuration-advanced"><summary>Advanced configuration</summary><p>For custom fields, saved routing examples, and intake enrichments. Changes here update the form above.</p><label>Configuration JSON<textarea className="code-input configuration-code" spellCheck={false} required disabled={busy} value={contentJson} onChange={(event) => { setContentJson(event.currentTarget.value); setValidation(null); setValidationMessage("Unsaved changes."); }} /></label></details>
|
|
209
|
+
</>}
|
|
196
210
|
<ValidationSummary validation={validation} message={validationMessage} />
|
|
211
|
+
</div>
|
|
197
212
|
<div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={busy} onClick={close}>Cancel</button><button className="secondary" type="submit" disabled={busy || !dirty}>{action === "save" ? "Saving…" : "Save draft"}</button><button className="secondary" type="button" disabled={busy} onClick={() => void validate()}>{action === "validate" ? "Checking…" : "Check"}</button><button className="primary" type="button" disabled={busy} onClick={() => void publish()}>{action === "publish" ? "Publishing…" : "Publish"}</button></div></div>
|
|
198
213
|
</form>}
|
|
199
214
|
</dialog>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
3
|
import { readable, relativeTime } from "../lib/format";
|
|
4
4
|
import { errorMessage, requestJson } from "../lib/http";
|
|
@@ -16,7 +16,7 @@ const tabs: Array<{ type: ConfigurationType; label: string }> = [
|
|
|
16
16
|
|
|
17
17
|
function ConfigurationCard({ item, type }: { item: ConfigurationSummary; type: ConfigurationType }) {
|
|
18
18
|
const updated = item.draft?.updatedAt || item.latestPublishedAt || "";
|
|
19
|
-
const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey);
|
|
19
|
+
const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey.replaceAll("-", " "));
|
|
20
20
|
return <article className="object-card configuration-card">
|
|
21
21
|
<div className="object-card-head"><span className="object-icon" aria-hidden="true">⌗</span><span className={`badge${item.draft?.validationStatus === "invalid" ? " urgent" : ""}`}>{item.draft ? "Draft" : "Published"}</span></div>
|
|
22
22
|
<h3>{title}</h3><p>{item.draft ? `Revision ${item.draft.revision} · ${readable(item.draft.validationStatus)}` : "No unpublished changes."}</p>
|
|
@@ -33,19 +33,27 @@ export function ConfigurationWorkspace() {
|
|
|
33
33
|
const [loading, setLoading] = useState(false);
|
|
34
34
|
const [refreshing, setRefreshing] = useState(false);
|
|
35
35
|
const [error, setError] = useState("");
|
|
36
|
+
const requestId = useRef(0);
|
|
37
|
+
const selectType = (next: ConfigurationType): void => {
|
|
38
|
+
if (next === type) return;
|
|
39
|
+
requestId.current += 1;
|
|
40
|
+
setConfigurations([]); setError(""); setLoading(true); setType(next);
|
|
41
|
+
};
|
|
36
42
|
|
|
37
43
|
const load = useCallback(async (refresh = false): Promise<void> => {
|
|
44
|
+
const id = ++requestId.current;
|
|
38
45
|
if (!canManage) { setConfigurations([]); return; }
|
|
39
46
|
if (refresh) setRefreshing(true); else setLoading(true);
|
|
40
47
|
setError("");
|
|
41
48
|
try {
|
|
42
49
|
const result = await requestJson<ConfigurationListResponse>(`/v1/admin/${configurationEndpoint(type)}`);
|
|
50
|
+
if (id !== requestId.current) return;
|
|
43
51
|
setConfigurations(result.configurations || []);
|
|
44
|
-
} catch (cause) { setError(errorMessage(cause, "Configuration could not be loaded.")); }
|
|
45
|
-
finally { setLoading(false); setRefreshing(false); }
|
|
52
|
+
} catch (cause) { if (id === requestId.current) setError(errorMessage(cause, "Configuration could not be loaded.")); }
|
|
53
|
+
finally { if (id === requestId.current) { setLoading(false); setRefreshing(false); } }
|
|
46
54
|
}, [canManage, type]);
|
|
47
55
|
|
|
48
|
-
useEffect(() => { if (shell.currentView === "configuration") void load(); }, [shell.currentView, shell.session?.actor.id, load]);
|
|
56
|
+
useEffect(() => { if (shell.currentView === "configuration") void load(); return () => { requestId.current += 1; }; }, [shell.currentView, shell.session?.actor.id, load]);
|
|
49
57
|
useEffect(() => {
|
|
50
58
|
const changed = (): void => { if (shell.currentView === "configuration") void load(true); };
|
|
51
59
|
window.addEventListener(configurationEvents.changed, changed);
|
|
@@ -56,10 +64,15 @@ export function ConfigurationWorkspace() {
|
|
|
56
64
|
const create = (): void => openConfigurationEditor(type, type === "routing_rules" ? "default" : "", type === "routing_rules" && Boolean(routing));
|
|
57
65
|
return <div className="page-stack" data-react-slice="configuration-workspace">
|
|
58
66
|
<PageHeader titleId="configuration-title" title="Configuration" description="Manage intake forms, routing, policies, and messages.">
|
|
59
|
-
<div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
|
|
67
|
+
<div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage || loading || refreshing} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
|
|
60
68
|
</PageHeader>
|
|
61
|
-
<div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} onClick={() =>
|
|
62
|
-
|
|
69
|
+
<div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab, index) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} tabIndex={type === tab.type ? 0 : -1} onClick={() => selectType(tab.type)} onKeyDown={event => {
|
|
70
|
+
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 : -1;
|
|
71
|
+
if (next < 0) return;
|
|
72
|
+
event.preventDefault(); selectType(tabs[next]!.type);
|
|
73
|
+
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[next]?.focus();
|
|
74
|
+
}} key={tab.type}>{tab.label}</button>)}</div>
|
|
75
|
+
{type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safety review comes first.</strong><span>Safeguards send sensitive cases to human reviewers. Other cases follow your routing rules and default queue.</span></div> : null}
|
|
63
76
|
<div className="object-grid configuration-list" aria-busy={loading || refreshing}>{loading ? <div className="skeleton-card" /> : configurations.length ? configurations.map((item) => <ConfigurationCard item={item} type={type} key={item.configurationKey} />) : error ? null : <div className="empty-product"><span aria-hidden="true">⌗</span><h2>No {configurationLabel(type).toLowerCase()} yet</h2><p>Create, check, and publish an immutable version.</p>{canManage ? <button className="primary" type="button" onClick={create}>Create draft</button> : null}</div>}</div>
|
|
64
77
|
<p className="error surface-error" role="alert">{error}</p>
|
|
65
78
|
</div>;
|
|
@@ -75,5 +75,5 @@ export interface ReportFormContent {
|
|
|
75
75
|
consent_notice: string;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export interface QueueSummary { id: string; name: string; status: string }
|
|
78
|
+
export interface QueueSummary { id: string; name: string; status: string; activePolicy?: { managementMode: string } | null }
|
|
79
79
|
export interface QueueListResponse { queues: QueueSummary[] }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Priority = "urgent" | "high" | "medium" | "low";
|
|
2
|
+
|
|
3
|
+
export function priorityName(priority: number): Priority {
|
|
4
|
+
if (priority >= 80) return "urgent";
|
|
5
|
+
if (priority >= 60) return "high";
|
|
6
|
+
if (priority >= 40) return "medium";
|
|
7
|
+
return "low";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function priorityLabel(priority: number): string {
|
|
11
|
+
const name = priorityName(priority);
|
|
12
|
+
return name[0].toUpperCase() + name.slice(1);
|
|
13
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore, type FormEvent } from 'react';
|
|
2
|
+
import { getShellSnapshot, subscribeToShell } from '../shell/store';
|
|
3
|
+
import { errorMessage, mutationHeaders, requestJson } from '../lib/http';
|
|
4
|
+
import { dispatchOpenReportSection } from '../reports/events';
|
|
5
|
+
import { dispatchOpenAppeal } from '../appeals/events';
|
|
6
|
+
import { dispatchNavigation } from '../shell/events';
|
|
7
|
+
import type { CaseEscalationItem, CaseEscalationList, CaseEscalationPolicy, EscalationContact } from '../../shared/case-escalations';
|
|
8
|
+
import './case-escalations.css';
|
|
9
|
+
|
|
10
|
+
interface Settings { policies: CaseEscalationPolicy[]; contacts: EscalationContact[]; queues: { id:string;name:string }[] }
|
|
11
|
+
interface RecoveryTask { id:string;state:string;queueId:string|null;taskType:string;context?:{title?:string;instructions?:string} }
|
|
12
|
+
const stamp = (value: string) => new Date(value).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
|
13
|
+
function recover(item: CaseEscalationItem, recovery: CaseEscalationItem['recovery'][number]) {
|
|
14
|
+
if (recovery.type === 'people') dispatchNavigation('people');
|
|
15
|
+
else if (recovery.type === 'appeal') dispatchOpenAppeal(item.sourceId);
|
|
16
|
+
else if (item.reportId) dispatchOpenReportSection(item.reportId,recovery.section ?? 'overview',recovery.type === 'task' ? item.sourceId : undefined);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function TaskRecovery({ item,onClose,onChanged }: { item:CaseEscalationItem;onClose:()=>void;onChanged:()=>void }) {
|
|
20
|
+
const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
|
|
21
|
+
const [task,setTask] = useState<RecoveryTask|null>(null),[queues,setQueues] = useState<Settings['queues']>([]);
|
|
22
|
+
const [queue,setQueue] = useState(''),[reason,setReason] = useState(''),[action,setAction] = useState('reassign');
|
|
23
|
+
const [error,setError] = useState(''),[busy,setBusy] = useState(false);
|
|
24
|
+
const [retry,setRetry] = useState(0);
|
|
25
|
+
const canAssign = Boolean(shell.session?.permissions.assignTasks),canCancel = Boolean(shell.session?.permissions.operateRuns);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const controller = new AbortController();setError('');
|
|
28
|
+
Promise.all([requestJson<{task:RecoveryTask}>(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}`,{signal:controller.signal}),canAssign ? requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}) : Promise.resolve({queues:[]})])
|
|
29
|
+
.then(([result,settings]) => {if (!controller.signal.aborted) {setTask(result.task);setQueue(result.task.queueId ?? '');setQueues(settings.queues);setAction(canAssign ? 'reassign' : 'cancel');}})
|
|
30
|
+
.catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'The task could not be loaded.'));});
|
|
31
|
+
return () => controller.abort();
|
|
32
|
+
},[item.sourceId,canAssign,retry]);
|
|
33
|
+
async function submit(event:FormEvent) {
|
|
34
|
+
event.preventDefault();setBusy(true);setError('');
|
|
35
|
+
try {await requestJson(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}/${action}`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({reason, ...(action === 'reassign' ? {queue_id:queue} : {})})});onChanged();onClose();}
|
|
36
|
+
catch(cause) {setError(errorMessage(cause,'The task changed or could not be recovered.'));} finally {setBusy(false);}
|
|
37
|
+
}
|
|
38
|
+
return <form className="case-escalation-settings" aria-label="Task recovery" onSubmit={event => {void submit(event);}}>
|
|
39
|
+
<h3>{task?.context?.title ?? 'Recover playbook task'}</h3>{task?.context?.instructions ? <p>{task.context.instructions}</p> : null}
|
|
40
|
+
{task ? <><p>Current state: {task.state}. Reassignment releases current editing access. Cancellation ends this task and sends the playbook a cancellation event; it does not complete the case.</p><label>Recovery action<select value={action} disabled={busy} onChange={event => setAction(event.currentTarget.value)}>{canAssign ? <option value="reassign">Return to an eligible queue</option> : null}{canCancel ? <option value="cancel">Cancel this task</option> : null}</select></label>{action === 'reassign' ? <label>Destination queue<select required disabled={busy} value={queue} onChange={event => setQueue(event.currentTarget.value)}><option value="">Choose a queue</option>{queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label> : null}<label>Recovery reason<textarea required minLength={3} maxLength={2000} rows={3} disabled={busy} value={reason} onChange={event => setReason(event.currentTarget.value)} /></label></> : !error ? <p role="status">Loading task…</p> : null}
|
|
41
|
+
{error ? <p className="error" role="alert">{error}{!task ? <button className="text-button" type="button" onClick={() => setRetry(value => value + 1)}>Retry loading task</button> : null}</p> : null}
|
|
42
|
+
<div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Close task recovery</button><button type="submit" className="primary" disabled={busy || !task || !['open','claimed'].includes(task.state) || !reason.trim()}>{busy ? 'Saving…' : action === 'cancel' ? 'Cancel task with reason' : 'Reassign task with reason'}</button></div>
|
|
43
|
+
</form>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function EscalationSettings({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
|
47
|
+
const [settings,setSettings] = useState<Settings | null>(null);
|
|
48
|
+
const [queue,setQueue] = useState('');
|
|
49
|
+
const [primary,setPrimary] = useState('');
|
|
50
|
+
const [backup,setBackup] = useState('');
|
|
51
|
+
const [acknowledgement,setAcknowledgement] = useState(60);
|
|
52
|
+
const [followUp,setFollowUp] = useState(240);
|
|
53
|
+
const [error,setError] = useState('');
|
|
54
|
+
const [busy,setBusy] = useState(false);
|
|
55
|
+
const [retry,setRetry] = useState(0);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}).then(result => {setSettings(result);setError('');if (!result.policies.some(value => value.queueId === null) && result.queues[0]) setQueue(result.queues[0].id);}).catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'Recovery contacts could not be loaded.'));});
|
|
59
|
+
return () => controller.abort();
|
|
60
|
+
},[retry]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const policy = settings?.policies.find(value => value.queueId === (queue || null));
|
|
63
|
+
setPrimary(policy?.primaryOperatorId ?? '');setBackup(policy?.backupOperatorId ?? '');setAcknowledgement(policy?.acknowledgementMinutes ?? 60);setFollowUp(policy?.followUpMinutes ?? 240);
|
|
64
|
+
},[settings,queue]);
|
|
65
|
+
async function save(event: FormEvent) {
|
|
66
|
+
event.preventDefault();setBusy(true);setError('');
|
|
67
|
+
try {
|
|
68
|
+
const policy = settings?.policies.find(value => value.queueId === (queue || null));
|
|
69
|
+
await requestJson('/v1/admin/case-escalation-policies',{method:'PUT',headers:mutationHeaders(),body:JSON.stringify({queueId:queue || null,primaryOperatorId:primary || null,backupOperatorId:backup || null,acknowledgementMinutes:acknowledgement,followUpMinutes:followUp,revision:policy?.revision ?? 0})});
|
|
70
|
+
onSaved();onClose();
|
|
71
|
+
} catch(cause) {setError(errorMessage(cause,'Recovery contacts could not be saved.'));} finally {setBusy(false);}
|
|
72
|
+
}
|
|
73
|
+
return <form className="case-escalation-settings" aria-label="Recovery contacts" onSubmit={event => {void save(event);}}>
|
|
74
|
+
<h3>Recovery contacts and deadlines</h3><p>The coordinator arranges recovery. Case ownership and approval permissions stay with their current holders.</p>
|
|
75
|
+
{!settings && !error ? <p role="status">Loading recovery contacts…</p> : settings ? <>
|
|
76
|
+
<label>Queue<select value={queue} disabled={busy} onChange={event => setQueue(event.currentTarget.value)}>{settings.policies.some(value => value.queueId === null) ? <option value="">Installation default</option> : null}{settings.queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label>
|
|
77
|
+
<div className="case-escalation-setting-grid">
|
|
78
|
+
<label>Primary contact<select value={primary} disabled={busy} onChange={event => setPrimary(event.currentTarget.value)}><option value="">Case owner, then eligible administrators</option>{settings.contacts.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
|
|
79
|
+
<label>Backup contact<select value={backup} disabled={busy} onChange={event => setBackup(event.currentTarget.value)}><option value="">Eligible owners and administrators</option>{settings.contacts.filter(person => person.id !== primary).map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
|
|
80
|
+
<label>Acknowledge within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={acknowledgement} onChange={event => setAcknowledgement(Number(event.currentTarget.value))} /></label>
|
|
81
|
+
<label>Follow up within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={followUp} onChange={event => setFollowUp(Number(event.currentTarget.value))} /></label>
|
|
82
|
+
</div><p>If a target passes, the item escalates to the backup and stays open until its source is repaired. Named contacts must have access to the queue.</p>
|
|
83
|
+
</> : null}
|
|
84
|
+
{error ? <p role="alert" className="error">{error}{!settings ? <> <button type="button" className="text-button" onClick={() => setRetry(value => value + 1)}>Retry</button></> : null}</p> : null}
|
|
85
|
+
<div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Cancel</button><button className="primary" type="submit" disabled={busy || !settings}>{busy ? 'Saving…' : 'Save recovery contacts'}</button></div>
|
|
86
|
+
</form>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function CaseEscalations({ compact = false }: { compact?: boolean }) {
|
|
90
|
+
const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
|
|
91
|
+
const active = shell.currentView === (compact ? 'command' : 'operations') && Boolean(shell.session?.permissions.readReports || shell.session?.permissions.readOperations);
|
|
92
|
+
const [data,setData] = useState<CaseEscalationList | null>(null);
|
|
93
|
+
const [error,setError] = useState('');
|
|
94
|
+
const [busy,setBusy] = useState('');
|
|
95
|
+
const [refreshing,setRefreshing] = useState(false);
|
|
96
|
+
const [settings,setSettings] = useState(false);
|
|
97
|
+
const [expanded,setExpanded] = useState(false);
|
|
98
|
+
const [status,setStatus] = useState('');
|
|
99
|
+
const [recoveryTask,setRecoveryTask] = useState<CaseEscalationItem|null>(null);
|
|
100
|
+
const load = useCallback(async (signal?: AbortSignal,cursor = '') => {
|
|
101
|
+
setRefreshing(true);
|
|
102
|
+
try {
|
|
103
|
+
const next = await requestJson<CaseEscalationList>(`/v1/admin/case-escalations${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`,{signal});
|
|
104
|
+
if (signal?.aborted) return;
|
|
105
|
+
setData(previous => cursor && previous ? {...next,items:[...previous.items,...next.items.filter(item => !previous.items.some(old => old.id === item.id))]} : next);setError('');
|
|
106
|
+
} catch(cause) {if (!signal?.aborted) setError(errorMessage(cause,'Recovery work could not be loaded.'));} finally {if (!signal?.aborted) setRefreshing(false);}
|
|
107
|
+
},[]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!active) return;
|
|
110
|
+
const controller = new AbortController();setData(null);setStatus('');void load(controller.signal);
|
|
111
|
+
const refresh = () => {if (document.visibilityState === 'visible') void load(controller.signal);};
|
|
112
|
+
const timer = window.setInterval(refresh,60_000);window.addEventListener('focus',refresh);
|
|
113
|
+
return () => {controller.abort();window.clearInterval(timer);window.removeEventListener('focus',refresh);setSettings(false);};
|
|
114
|
+
},[active,shell.session?.actor.id,load]);
|
|
115
|
+
if (!active) return null;
|
|
116
|
+
const items = compact && !expanded ? data?.items.slice(0,3) : data?.items;
|
|
117
|
+
async function acknowledge(item: CaseEscalationItem) {
|
|
118
|
+
setBusy(item.id);setStatus('');setError('');
|
|
119
|
+
try {
|
|
120
|
+
await requestJson(`/v1/admin/case-escalations/${encodeURIComponent(item.id)}/acknowledge`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({revision:item.revision})});
|
|
121
|
+
setStatus('You are responsible for recovery. Open the affected work below to repair the cause.');await load();
|
|
122
|
+
} catch(cause) {setError(errorMessage(cause,'Responsibility could not be recorded.'));} finally {setBusy('');}
|
|
123
|
+
}
|
|
124
|
+
return <section className="surface case-escalations" aria-label="Cases needing recovery" aria-busy={refreshing}>
|
|
125
|
+
<div className="surface-head"><div><h2>Cases needing recovery{data ? ` (${data.total})` : ''}</h2><p>{data ? `${data.unacknowledged} awaiting acknowledgement · ${data.escalated} escalated to backup` : 'Checking stalled cases and participant delivery.'}</p></div><div className="case-escalation-buttons"><button type="button" className="secondary" disabled={refreshing || Boolean(busy)} onClick={() => {void load();}}>Refresh recovery</button>{shell.session?.permissions.manageConfiguration ? <button type="button" className="text-button" onClick={() => setSettings(value => !value)}>Recovery contacts</button> : null}</div></div>
|
|
126
|
+
{settings ? <EscalationSettings onClose={() => setSettings(false)} onSaved={() => {setStatus('Recovery contacts saved.');void load();}} /> : null}
|
|
127
|
+
{status ? <p role="status" className="case-escalation-status">{status}</p> : null}
|
|
128
|
+
{error ? <p role="alert" className="error">{error} <button type="button" className="text-button" onClick={() => {void load();}}>Retry loading recovery</button></p> : null}
|
|
129
|
+
{!data && refreshing ? <p role="status">Loading recovery work…</p> : null}
|
|
130
|
+
{data && !data.total ? <p className="case-escalation-empty">No detected cases need recovery. Open work continues against its configured deadlines.</p> : null}
|
|
131
|
+
<div className="case-escalation-list">{items?.map(item => <article className="case-escalation-item" key={item.id} aria-label={`${item.title}${item.reportReference ? `: ${item.reportReference}` : ''}`}>
|
|
132
|
+
<div className="case-escalation-item-head"><div><h3>{item.title}</h3><p>{item.reportReference ?? (item.reportId ? 'Case reference unavailable' : 'Workspace task')}{item.queueName ? ` · ${item.queueName}` : ''}</p></div><span className={`badge${item.level ? ' urgent' : ''}`}>{item.state === 'acknowledged' ? 'Recovery in progress' : item.level ? 'Backup needed' : 'Needs acknowledgement'}</span></div>
|
|
133
|
+
<p>{item.description}</p>
|
|
134
|
+
<dl><div><dt>Responsible</dt><dd>{item.acknowledgedBy?.displayName ?? item.responsible?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Backup</dt><dd>{item.backup?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Source target</dt><dd>{stamp(item.sourceDueAt)}</dd></div><div><dt>{item.state === 'acknowledged' ? 'Follow up by' : 'Acknowledge by'}</dt><dd>{stamp(item.responseDueAt)}</dd></div></dl>
|
|
135
|
+
{item.contactWarning ? <p className="case-escalation-warning">{item.contactWarning}</p> : null}
|
|
136
|
+
{item.acknowledgedAt ? <p>Acknowledged {stamp(item.acknowledgedAt)}. This item clears when the underlying problem is repaired.</p> : null}
|
|
137
|
+
<div className="case-escalation-buttons">{item.canAcknowledge ? <button type="button" className="primary" disabled={Boolean(busy)} onClick={() => {void acknowledge(item);}}>{busy === item.id ? 'Recording…' : 'Take responsibility for recovery'}</button> : null}{item.recovery.map(link => <button key={link.type} className="secondary" type="button" onClick={() => link.type === 'task_recovery' ? setRecoveryTask(item) : recover(item,link)}>{link.label}</button>)}</div>
|
|
138
|
+
{recoveryTask?.id === item.id ? <TaskRecovery item={item} onClose={() => setRecoveryTask(null)} onChanged={() => {setStatus('Task recovery recorded. Its source will be checked again.');void load();}} /> : null}
|
|
139
|
+
</article>)}</div>
|
|
140
|
+
{compact && !expanded && (data?.total ?? 0) > 3 ? <button type="button" className="text-button" onClick={() => setExpanded(true)}>Show all recovery work</button> : data?.nextCursor ? <button type="button" className="secondary" disabled={refreshing} onClick={() => {void load(undefined,data.nextCursor!);}}>Load more recovery work</button> : null}
|
|
141
|
+
</section>;
|
|
142
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
|
+
import { CaseEscalations } from './CaseEscalations';
|
|
3
4
|
import { readable, relativeTime } from "../lib/format";
|
|
4
5
|
import {
|
|
5
6
|
dispatchAcknowledgeOperationsIncident,
|
|
@@ -60,6 +61,7 @@ export function OperationsWorkspace() {
|
|
|
60
61
|
<PageHeader titleId="operations-title" title="Operations" description="Review failed deliveries, exhausted retries, and other incidents.">
|
|
61
62
|
<div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchOperationsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
|
|
62
63
|
</PageHeader>
|
|
64
|
+
<CaseEscalations />
|
|
63
65
|
<section className="surface operations-surface" aria-labelledby="operations-title">
|
|
64
66
|
<div className="operation-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
|
|
65
67
|
<Metric label="Routing failures" value={snapshot.loading ? "—" : routingFailures} detail="Failed or dead-letter jobs" urgent={routingFailures > 0} />
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.case-escalations { margin-block: 1rem; min-width: 0; padding: 1.25rem; }
|
|
2
|
+
.case-escalations .surface-head { gap: 1rem; flex-wrap: wrap; padding: 0 0 1rem; margin-bottom: 1rem; }
|
|
3
|
+
.case-escalations p { color: var(--muted, #5c6862); line-height: 1.5; }
|
|
4
|
+
.case-escalation-list { display: grid; gap: 1rem; }
|
|
5
|
+
.case-escalation-item { border: 1px solid var(--line, #d8dfda); border-radius: .75rem; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
|
|
6
|
+
.case-escalation-item-head { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; flex-wrap: wrap; }
|
|
7
|
+
.case-escalation-item h3 { margin: 0; font-size: 1rem; }
|
|
8
|
+
.case-escalation-item-head p { margin: .3rem 0 0; font-size: .875rem; }
|
|
9
|
+
.case-escalation-item dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; }
|
|
10
|
+
.case-escalation-item dt { font-size: .8rem; color: var(--muted, #5c6862); }
|
|
11
|
+
.case-escalation-item dd { margin: .2rem 0 0; font-size: .9rem; }
|
|
12
|
+
.case-escalation-buttons { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
13
|
+
.case-escalation-buttons button { min-height: 44px; white-space: normal; }
|
|
14
|
+
.case-escalation-warning { border-inline-start: 3px solid #af7820; padding-inline-start: .75rem; }
|
|
15
|
+
.case-escalation-settings { padding: 1rem; margin-block: 1rem; border: 1px solid var(--line, #d8dfda); border-radius: .75rem; }
|
|
16
|
+
.case-escalation-settings label { display: grid; gap: .35rem; min-width: 0; font-size: .875rem; }
|
|
17
|
+
.case-escalation-setting-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; margin-block: .75rem; }
|
|
18
|
+
.case-escalation-settings select,.case-escalation-settings input,.case-escalation-settings textarea { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
|
|
19
|
+
.case-escalation-status { padding: .75rem; background: var(--brand-soft, #e8f1e9); border-radius: .5rem; }
|
|
20
|
+
@media (max-width: 560px) { .case-escalations { padding: 1rem; } .case-escalation-setting-grid { grid-template-columns: minmax(0,1fr); } .case-escalation-buttons { align-items: stretch; } .case-escalation-buttons button { flex: 1 1 auto; } }
|
|
@@ -4,6 +4,7 @@ import { PageHeader } from "../components/PageHeader";
|
|
|
4
4
|
import { errorMessage, postJson, requestJson } from "../lib/http";
|
|
5
5
|
import { relativeTime } from "../lib/format";
|
|
6
6
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
7
|
+
import { PersonAccessDialog } from "./PersonAccessDialog";
|
|
7
8
|
import type { InvitationResponse, PasswordResetResponse, PeopleResponse, WorkspaceInvitation, WorkspacePerson } from "./types";
|
|
8
9
|
|
|
9
10
|
const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
|
|
@@ -24,6 +25,8 @@ export function PeopleWorkspace() {
|
|
|
24
25
|
const [error, setError] = useState("");
|
|
25
26
|
const [inviteUrl, setInviteUrl] = useState("");
|
|
26
27
|
const [resetMessage, setResetMessage] = useState("");
|
|
28
|
+
const [editing, setEditing] = useState<WorkspacePerson | null>(null);
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
27
30
|
const canManage = shell.session?.permissions.managePeople === true;
|
|
28
31
|
const isOwner = shell.session?.roles.includes("owner") === true;
|
|
29
32
|
|
|
@@ -38,10 +41,10 @@ export function PeopleWorkspace() {
|
|
|
38
41
|
finally { setLoading(false); }
|
|
39
42
|
};
|
|
40
43
|
|
|
41
|
-
useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); void load(); }, [shell.session?.actor.id, canManage]);
|
|
44
|
+
useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); setEditing(null); void load(); }, [shell.session?.actor.id, canManage]);
|
|
42
45
|
|
|
43
46
|
const invite = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
44
|
-
event.preventDefault(); setPending("invite"); setError(""); setInviteUrl("");
|
|
47
|
+
event.preventDefault(); setPending("invite"); setError(""); setInviteUrl(""); setCopied(false);
|
|
45
48
|
try {
|
|
46
49
|
const result = await postJson<InvitationResponse>("/v1/admin/people", { email, role });
|
|
47
50
|
setInviteUrl(result.invitation.inviteUrl); setEmail(""); await load();
|
|
@@ -71,11 +74,12 @@ export function PeopleWorkspace() {
|
|
|
71
74
|
</PageHeader>
|
|
72
75
|
<section className="surface people-surface" aria-labelledby="people-title">
|
|
73
76
|
<form className="invite-form" onSubmit={invite}><label>Email<input type="email" autoComplete="off" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></label><label>Role<select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="reviewer">Analyst</option><option value="appeal_reviewer">Appeal analyst</option><option value="auditor">Auditor</option>{isOwner ? <option value="administrator">Administrator</option> : null}</select></label><button className="primary" type="submit" disabled={pending === "invite"}>{pending === "invite" ? "Sending…" : "Send invitation"}</button></form>
|
|
74
|
-
{inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => void copy(inviteUrl)}>Copy link</button></div> : null}
|
|
77
|
+
{inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => { void copy(inviteUrl).then(() => setCopied(true)).catch(() => setError("Copy failed. Select the invitation link and copy it manually.")); }}>{copied ? "Copied" : "Copy link"}</button></div> : null}
|
|
75
78
|
{resetMessage ? <p className="form-message" role="status">{resetMessage}</p> : null}
|
|
76
|
-
<div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
|
|
79
|
+
<div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}{person.status === "suspended" ? " · Suspended" : ""}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <div className="people-actions"><button className="secondary compact-button" type="button" onClick={() => setEditing(person)}>Edit access</button><button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button></div> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
|
|
77
80
|
<p className="error surface-error" role="alert">{error}</p>
|
|
78
81
|
</section>
|
|
82
|
+
<PersonAccessDialog person={editing} owner={isOwner} onClose={() => setEditing(null)} onSaved={() => { setResetMessage("Workspace access updated."); void load(); }} />
|
|
79
83
|
</div>
|
|
80
84
|
);
|
|
81
85
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
3
|
+
import type { WorkspacePerson } from "./types";
|
|
4
|
+
|
|
5
|
+
const roles = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", auditor: "Auditor", administrator: "Administrator", owner: "Infrastructure owner" };
|
|
6
|
+
|
|
7
|
+
export function PersonAccessDialog({ person, owner, onClose, onSaved }: { person: WorkspacePerson | null; owner: boolean; onClose: () => void; onSaved: () => void }) {
|
|
8
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
9
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
10
|
+
const [status, setStatus] = useState("active");
|
|
11
|
+
const [pending, setPending] = useState(false);
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setSelected(person?.roles || []); setStatus(person?.status || "active"); setError("");
|
|
15
|
+
if (person && !ref.current?.open) ref.current?.showModal();
|
|
16
|
+
if (!person && ref.current?.open) ref.current.close();
|
|
17
|
+
}, [person]);
|
|
18
|
+
const close = () => { if (!pending) onClose(); };
|
|
19
|
+
const save = async (event: FormEvent<HTMLFormElement>) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
if (!person || pending) return;
|
|
22
|
+
if (!selected.length) { setError("Choose at least one role."); return; }
|
|
23
|
+
if (status === "suspended" && person.status !== "suspended" && !window.confirm(`Suspend ${person.displayName}? Their active sessions will be signed out.`)) return;
|
|
24
|
+
setPending(true); setError("");
|
|
25
|
+
try {
|
|
26
|
+
await requestJson(`/v1/admin/people/${encodeURIComponent(person.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ roles: selected, status }) });
|
|
27
|
+
onSaved(); onClose();
|
|
28
|
+
} catch (cause) { setError(errorMessage(cause, "Access could not be updated.")); }
|
|
29
|
+
finally { setPending(false); }
|
|
30
|
+
};
|
|
31
|
+
return <dialog ref={ref} className="editor-dialog" aria-labelledby="person-access-title" onCancel={(event) => { event.preventDefault(); close(); }}>
|
|
32
|
+
<div className="detail-head"><div><h2 id="person-access-title">Edit access</h2><p>{person?.displayName} · {person?.email}</p></div><button type="button" className="icon-button" aria-label="Close access editor" disabled={pending} onClick={close}>×</button></div>
|
|
33
|
+
<form className="editor-form" onSubmit={(event) => void save(event)}>
|
|
34
|
+
<fieldset disabled={pending}><legend>Workspace roles</legend>{Object.entries(roles).filter(([role]) => owner || !["owner", "administrator"].includes(role)).map(([role, label]) => <label className="checkbox" key={role}><input type="checkbox" checked={selected.includes(role)} onChange={(event) => { const checked = event.currentTarget.checked; setSelected((current) => checked ? [...current, role] : current.filter((value) => value !== role)); }} />{label}</label>)}</fieldset>
|
|
35
|
+
<label>Account status<select disabled={pending} value={status} onChange={(event) => setStatus(event.currentTarget.value)}><option value="active">Active</option><option value="suspended">Suspended</option></select></label>
|
|
36
|
+
<p className="form-help">Suspending access signs out active sessions. Reactivating access lets this teammate sign in again.</p>
|
|
37
|
+
<p role="alert" className="error">{error}</p><div className="editor-footer"><button type="button" className="secondary" disabled={pending} onClick={close}>Cancel</button><button type="submit" className="primary" disabled={pending}>{pending ? "Saving…" : "Save access"}</button></div>
|
|
38
|
+
</form>
|
|
39
|
+
</dialog>;
|
|
40
|
+
}
|
|
@@ -116,7 +116,7 @@ export function ProfileWorkspace() {
|
|
|
116
116
|
<section className="surface profile-surface" aria-labelledby="profile-title">
|
|
117
117
|
{profile ? <div className="profile-layout">
|
|
118
118
|
<div className="profile-photo-card">
|
|
119
|
-
<div className="profile-avatar-preview" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
|
|
119
|
+
<div className="profile-avatar-preview" role="img" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
|
|
120
120
|
<label className={`secondary upload-button${pending ? " disabled" : ""}`} aria-disabled={Boolean(pending)}>{pending === "upload" ? "Uploading…" : "Choose picture"}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={Boolean(pending)} onChange={(event) => void upload(event)} /></label>
|
|
121
121
|
{profile.avatarUrl ? <button className="text-button" type="button" disabled={Boolean(pending)} onClick={() => void remove()}>{pending === "remove" ? "Removing…" : "Remove picture"}</button> : null}
|
|
122
122
|
<small>JPEG, PNG, or WebP. Maximum 2 MB.</small>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { dateTime } from "../lib/format";
|
|
3
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
4
|
+
import { dispatchOpenReport } from "../reports/events";
|
|
5
|
+
import { openChannelDialog, settingsEvents } from "../settings/events";
|
|
6
|
+
import { dispatchNavigation } from "../shell/events";
|
|
7
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
8
|
+
import { registryEvents } from "./events";
|
|
9
|
+
import { IntegrationVerification } from "./IntegrationVerification";
|
|
10
|
+
import type { IntegrationProgress } from "../../shared/integration-progress";
|
|
11
|
+
|
|
12
|
+
export function IntegrationSetup({onTools}:{onTools:(kind:"enrichment"|"action")=>void}) {
|
|
13
|
+
const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
|
|
14
|
+
const permissions=shell.session?.permissions??{};
|
|
15
|
+
const [progress,setProgress]=useState<IntegrationProgress|null>(null),[error,setError]=useState("");
|
|
16
|
+
const [loading,setLoading]=useState(false);const version=useRef(0);
|
|
17
|
+
const access=JSON.stringify({actor:shell.session?.actor.id,permissions,scope:shell.session?.queueScope});
|
|
18
|
+
const load=useCallback(async()=>{const request=++version.current;setLoading(true);setError("");try{const result=await requestJson<IntegrationProgress>("/v1/admin/integration-progress");if(request===version.current)setProgress(result);}catch(cause){if(request===version.current)setError(errorMessage(cause,"Setup evidence could not refresh."));}finally{if(request===version.current)setLoading(false);}},[]);
|
|
19
|
+
useEffect(()=>{version.current++;setProgress(null);if(permissions.manageComponents)void load();return()=>{version.current++;};},[access,load]);
|
|
20
|
+
useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);};},[load]);
|
|
21
|
+
const [expanded,setExpanded]=useState<string|null>("intake");
|
|
22
|
+
const config=progress?.configuration;
|
|
23
|
+
const configured=(count:number|null|undefined,label:string)=>count==null?"Configuration access required":`${count} ${label}`;
|
|
24
|
+
const stages=[
|
|
25
|
+
{key:"intake" as const,title:"Receive a report",description:"Start with a hosted form or product widget. Signed customer context keeps identity and content references tied to your application.",configuration:configured(config?.reportingRoutes,"configured reporting routes"),action:"Set up reporting",allowed:permissions.manageIntegrations,run:openChannelDialog},
|
|
26
|
+
{key:"context" as const,title:"Bring in the right context",description:"Publish read-only customer tools for the evidence your reviewers need. Declare the fields each playbook may request.",configuration:configured(config?.customerReads,"published customer reads"),action:"Choose read tools",allowed:true,run:()=>onTools("enrichment")},
|
|
27
|
+
{key:"action" as const,title:"Confirm the customer action",description:"Choose exact targets and approval rules. The case waits for your application to confirm the approved change.",configuration:configured(config?.customerActions,"published customer actions"),action:"Choose action tools",allowed:true,run:()=>onTools("action")},
|
|
28
|
+
{key:"notice" as const,title:"Reach each participant",description:"Write separate public outcomes for the reporter and affected person. A delivery handoff stays distinct from someone reading the notice.",configuration:config?.noticeChannel==null?"Configuration access required":config.noticeChannel?"Participant channel configured":"Participant channel needed",action:"Review notice setup",allowed:permissions.manageInstallation||permissions.manageIntegrations,run:()=>dispatchNavigation("settings")},
|
|
29
|
+
{key:"playbook" as const,title:"Run a complete case",description:"Publish and activate a tested playbook, then submit a fresh report through your customer channel. Follow it from review to the confirmed outcome.",configuration:configured(config?.activePlaybooks,"active playbook queue assignments"),action:"Open playbooks",allowed:permissions.manageWorkflows,run:()=>dispatchNavigation("workflows")},
|
|
30
|
+
];
|
|
31
|
+
return <>{permissions.manageIntegrations&&permissions.manageWorkflows&&permissions.readReports&&permissions.readEvidence&&progress?.canReadCases===true?<IntegrationVerification key={access}/>:null}<section className="integration-setup surface" aria-label="Customer connection setup">
|
|
32
|
+
<div className="surface-head"><div><h2>Customer connection setup reference</h2><p>Start with reporting. Add customer context, approved actions and participant delivery as your playbook needs them.</p></div><button className="secondary" type="button" disabled={loading} onClick={()=>void load()}>{loading?"Checking records…":"Refresh historical evidence"}</button></div>
|
|
33
|
+
{progress?.localSandbox?<p className="case-guidance">Local sandbox records. Model fixtures and local customer receivers do not verify production model judgement or external delivery.</p>:null}
|
|
34
|
+
<ol className="integration-stages" aria-busy={loading}>{stages.map((stage,index)=>{const evidence=progress?.evidence[stage.key];const open=expanded===stage.key;return <li key={stage.key}>
|
|
35
|
+
<h3><button type="button" className="integration-stage-toggle" aria-expanded={open} aria-controls={`integration-stage-${stage.key}`} onClick={()=>setExpanded(open?null:stage.key)}><span className="integration-stage-number" aria-hidden="true">{index+1}</span><span>{stage.title}<small>{progress?stage.configuration:"Loading configuration…"}</small></span><span aria-hidden="true">{open?"−":"+"}</span></button></h3>
|
|
36
|
+
<div id={`integration-stage-${stage.key}`} hidden={!open} className="integration-stage-detail"><p>{stage.description}</p><div className="integration-stage-evidence">{evidence?<><strong>Historical case evidence</strong><button className="text-button" type="button" onClick={()=>dispatchOpenReport(evidence.reportId)}>{evidence.reference}</button><small>{dateTime(evidence.observedAt)}</small></>:<span>{progress?.canReadCases===false?"Case evidence is restricted for this role":progress?"No matching case evidence in your access yet":"Checking saved evidence…"}</span>}</div>{stage.allowed?<button className="secondary" type="button" onClick={stage.run}>{stage.action}</button>:<small>Ask your workspace administrator to complete this step.</small>}</div>
|
|
37
|
+
</li>;})}</ol>
|
|
38
|
+
<p className="form-help">These are saved configuration and case records, not a live health check. Run a fresh case after changes. Contract examples alone do not complete this journey.</p>
|
|
39
|
+
{error?<p className="error" role="alert">{error} {progress?"Previously loaded records remain visible.":""}</p>:null}
|
|
40
|
+
</section></>;
|
|
41
|
+
}
|