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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { JsonObject, JsonValue, QueueSummary } from "./types";
|
|
2
|
+
|
|
3
|
+
interface BuilderProps { value: JsonObject; onChange: (value: JsonObject) => void; disabled: boolean }
|
|
4
|
+
const objects = (value: JsonValue | undefined): JsonObject[] => Array.isArray(value) ? value.filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
|
|
5
|
+
const string = (value: JsonValue | undefined): string => value == null ? "" : String(value);
|
|
6
|
+
|
|
7
|
+
function QueueChoice({ label, help, value, queues, onChange, disabled, humanOnly = false }: {
|
|
8
|
+
label: string; help: string; value: string; queues: QueueSummary[]; onChange: (value: string) => void; disabled: boolean; humanOnly?: boolean;
|
|
9
|
+
}) {
|
|
10
|
+
const choices = queues.filter(queue => queue.id === value || queue.status === "active" && (!humanOnly || queue.activePolicy?.managementMode === "human"));
|
|
11
|
+
return <label>{label}<small>{help}</small><select aria-label={label} required disabled={disabled} value={value} onChange={event => onChange(event.currentTarget.value)}>
|
|
12
|
+
<option value="" disabled>Choose a queue</option>
|
|
13
|
+
{value && !choices.some(queue => queue.id === value) ? <option value={value}>{value} · unavailable</option> : null}
|
|
14
|
+
{choices.map(queue => <option value={queue.id} key={queue.id}>{queue.name}{queue.status === "active" ? "" : ` · ${queue.status}`}</option>)}
|
|
15
|
+
</select></label>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fields = [["reason_code", "Report reason"], ["source", "Report source"], ["target_type", "Content type"], ["priority", "Case priority"], ["safeguard.requires_human", "Human review required"]];
|
|
19
|
+
const operators = [["eq", "is"], ["neq", "is not"], ["in", "is one of"], ["not_in", "is not one of"], ["contains", "contains"], ["exists", "has a value"], ["gt", "is greater than"], ["gte", "is at least"], ["lt", "is less than"], ["lte", "is at most"]];
|
|
20
|
+
|
|
21
|
+
function Condition({ value, onChange, onRemove, disabled, index }: { value: JsonObject; onChange: (value: JsonObject) => void; onRemove: () => void; disabled: boolean; index: number }) {
|
|
22
|
+
const field = string(value.field), operator = string(value.operator);
|
|
23
|
+
const list = operator === "in" || operator === "not_in";
|
|
24
|
+
const numeric = typeof value.value === "number" || Array.isArray(value.value) && value.value.some(item => typeof item === "number") || field === "priority" || ["gt", "gte", "lt", "lte"].includes(operator);
|
|
25
|
+
const boolean = typeof value.value === "boolean" || Array.isArray(value.value) && value.value.some(item => typeof item === "boolean") || field === "safeguard.requires_human";
|
|
26
|
+
const changeOperator = (next: string) => {
|
|
27
|
+
const { value: previous, ...rest } = value;
|
|
28
|
+
const initial = boolean ? true : numeric ? 0 : "";
|
|
29
|
+
onChange(next === "exists" ? { ...rest, operator: next } : { ...rest, operator: next, value: next === "in" || next === "not_in" ? Array.isArray(previous) ? previous : [previous ?? initial] : Array.isArray(previous) ? previous[0] ?? initial : previous ?? initial });
|
|
30
|
+
};
|
|
31
|
+
return <div className="routing-condition" role="group" aria-label={`Condition ${index + 1}`}>
|
|
32
|
+
<label>Field<select aria-label={`Condition ${index + 1} field`} value={fields.some(([key]) => key === field) ? field : "custom"} disabled={disabled} onChange={event => {
|
|
33
|
+
const next = event.currentTarget.value;
|
|
34
|
+
const initial = next === "priority" ? 0 : next === "safeguard.requires_human" ? true : "";
|
|
35
|
+
onChange({ ...value, field: next === "custom" ? "answer.details" : next, ...(operator === "exists" ? {} : { value: list ? [initial] : initial }) });
|
|
36
|
+
}}>{fields.map(([key, label]) => <option value={key} key={key}>{label}</option>)}<option value="custom">Answer, trusted fact, or enrichment</option></select></label>
|
|
37
|
+
{!fields.some(([key]) => key === field) ? <label>Field name<input aria-label={`Condition ${index + 1} field name`} value={field} disabled={disabled} required onChange={event => onChange({ ...value, field: event.currentTarget.value })} /></label> : null}
|
|
38
|
+
<label>Comparison<select aria-label={`Condition ${index + 1} comparison`} value={operator} disabled={disabled} onChange={event => changeOperator(event.currentTarget.value)}>{operators.map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
|
39
|
+
{operator === "exists" ? null : boolean ? <label>{list ? "Values" : "Value"}<select required aria-label={`Condition ${index + 1} value`} value={list ? [true, false].filter(item => Array.isArray(value.value) && value.value.includes(item)).join(",") : String(value.value)} disabled={disabled} onChange={event => onChange({ ...value, value: list ? event.currentTarget.value.split(",").map(item => item === "true") : event.currentTarget.value === "true" })}><option value="" disabled>Choose a value</option><option value="true">Yes</option><option value="false">No</option>{list ? <option value="true,false">Yes or no</option> : null}</select></label> : <label>{list ? "Values, separated by commas" : "Value"}<input aria-label={`Condition ${index + 1} value`} type={numeric && !list ? "number" : "text"} step="any" pattern={numeric && list ? "-?\\d+(?:\\.\\d+)?(?:\\s*,\\s*-?\\d+(?:\\.\\d+)?)*" : undefined} disabled={disabled} required value={Array.isArray(value.value) ? value.value.join(", ") : string(value.value)} onChange={event => {
|
|
40
|
+
const input = event.currentTarget.value;
|
|
41
|
+
onChange({ ...value, value: list ? input.split(",").map(item => item.trim()).map(item => numeric && item !== "" && Number.isFinite(Number(item)) ? Number(item) : item) : numeric && input !== "" ? Number(input) : input });
|
|
42
|
+
}} /></label>}
|
|
43
|
+
<button type="button" className="secondary" disabled={disabled} aria-label={`Remove condition ${index + 1}`} onClick={onRemove}>Remove</button>
|
|
44
|
+
</div>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function RoutingBuilder({ value, onChange, queues, disabled }: BuilderProps & { queues: QueueSummary[] }) {
|
|
48
|
+
const rules = objects(value.rules).sort((a, b) => Number(b.priority) - Number(a.priority) || string(a.id).localeCompare(string(b.id)));
|
|
49
|
+
const update = (index: number, next: JsonObject) => onChange({ ...value, rules: rules.map((rule, i) => i === index ? next : rule) });
|
|
50
|
+
const reorder = (index: number, destination: number) => {
|
|
51
|
+
const next = [...rules];
|
|
52
|
+
next.splice(destination, 0, next.splice(index, 1)[0]!);
|
|
53
|
+
onChange({ ...value, rules: next.map((rule, i) => ({ ...rule, priority: 100_000 - i })) });
|
|
54
|
+
};
|
|
55
|
+
return <div className="configuration-builder">
|
|
56
|
+
<section className="configuration-section"><h3>Where should cases go?</h3><p>Choose who handles new cases and who handles cases that need a safety review.</p>
|
|
57
|
+
<QueueChoice label="Default queue" help="Receives cases that do not match a rule below." value={string(value.default_queue_id)} queues={queues} onChange={next => onChange({ ...value, default_queue_id: next })} disabled={disabled} />
|
|
58
|
+
<QueueChoice label="Safety review queue" help="Human reviewers handle cases flagged by safeguards, before any routing rule or AI." value={string(value.safeguard_queue_id)} queues={queues} humanOnly onChange={next => onChange({ ...value, safeguard_queue_id: next })} disabled={disabled} />
|
|
59
|
+
</section>
|
|
60
|
+
<section className="configuration-section"><div className="configuration-section-heading"><div><h3>Routing rules</h3><p>Checked from top to bottom. The first matching rule chooses the queue.</p></div><button className="secondary" type="button" disabled={disabled || rules.length >= 100} onClick={() => onChange({ ...value, rules: [...rules, { id: `rule-${crypto.randomUUID()}`, priority: 0, queue_id: value.default_queue_id || "", match: "all", conditions: [{ field: "reason_code", operator: "eq", value: "" }] }].map((rule, i) => ({ ...rule, priority: 100_000 - i })) })}>Add rule</button></div>
|
|
61
|
+
{!rules.length ? <p className="configuration-empty">All cases go to the default queue, unless a safeguard requires human review.</p> : rules.map((rule, index) => <fieldset className="routing-rule" key={string(rule.id)}><legend>Rule {index + 1}</legend>
|
|
62
|
+
<div className="configuration-rule-actions"><button type="button" className="secondary" aria-label={`Move rule ${index + 1} up`} disabled={disabled || index === 0} onClick={() => reorder(index, index - 1)}>Move up</button><button type="button" className="secondary" aria-label={`Move rule ${index + 1} down`} disabled={disabled || index === rules.length - 1} onClick={() => reorder(index, index + 1)}>Move down</button><button type="button" className="secondary" disabled={disabled} aria-label={`Remove rule ${index + 1}`} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule</button></div>
|
|
63
|
+
<label>Match<select aria-label={`Rule ${index + 1} match`} value={string(rule.match)} disabled={disabled} onChange={event => update(index, { ...rule, match: event.currentTarget.value })}><option value="all">All of these conditions</option><option value="any">Any of these conditions</option></select></label>
|
|
64
|
+
{objects(rule.conditions).map((condition, i) => <Condition key={i} index={i} value={condition} disabled={disabled} onChange={next => update(index, { ...rule, conditions: objects(rule.conditions).map((item, j) => i === j ? next : item) })} onRemove={() => update(index, { ...rule, conditions: objects(rule.conditions).filter((_, j) => i !== j) })} />)}
|
|
65
|
+
{!objects(rule.conditions).length ? <p>This rule matches {rule.match === "all" ? "every case" : "no cases"}.</p> : null}
|
|
66
|
+
<button className="secondary" type="button" disabled={disabled || objects(rule.conditions).length >= 50} onClick={() => update(index, { ...rule, conditions: [...objects(rule.conditions), { field: "reason_code", operator: "eq", value: "" }] })}>Add condition</button>
|
|
67
|
+
<QueueChoice label={`Rule ${index + 1} queue`} help="Send matching cases to this queue." queues={queues} value={string(rule.queue_id)} disabled={disabled} onChange={next => update(index, { ...rule, queue_id: next })} />
|
|
68
|
+
</fieldset>)}
|
|
69
|
+
</section>
|
|
70
|
+
{objects(value.fixtures).length || objects(value.intake_enrichments).length ? <p className="configuration-retained">{objects(value.fixtures).length} saved routing examples and {objects(value.intake_enrichments).length} intake enrichments are retained. Use Advanced configuration to inspect them.</p> : null}
|
|
71
|
+
</div>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function PolicyBuilder({ value, onChange, disabled }: BuilderProps) {
|
|
75
|
+
const rules = objects(value.rules);
|
|
76
|
+
return <div className="configuration-builder"><label>Policy title<input required minLength={3} maxLength={160} disabled={disabled} value={string(value.title)} onChange={event => onChange({ ...value, title: event.currentTarget.value })} /></label>
|
|
77
|
+
<section className="configuration-section"><h3>Decision rules</h3><p>Give reviewers clear rules to apply when deciding a case.</p>
|
|
78
|
+
{rules.map((rule, index) => <fieldset key={index}><legend>Rule {index + 1}</legend><label>Rule title<input aria-label={`Rule ${index + 1} title`} required minLength={3} maxLength={200} disabled={disabled} value={string(rule.title)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, title: event.currentTarget.value } : item) })} /></label>
|
|
79
|
+
<label>Guidance for reviewers<textarea aria-label={`Rule ${index + 1} guidance`} rows={3} disabled={disabled} value={string(rule.description)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, description: event.currentTarget.value } : item) })} /></label>
|
|
80
|
+
<button className="secondary" type="button" disabled={disabled || rules.length === 1} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule {index + 1}</button></fieldset>)}
|
|
81
|
+
<button className="secondary" type="button" disabled={disabled || rules.length >= 500} onClick={() => onChange({ ...value, rules: [...rules, { code: `rule-${crypto.randomUUID()}`, title: "" }] })}>Add rule</button>
|
|
82
|
+
</section></div>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function MessageBuilder({ value, onChange, disabled }: BuilderProps) {
|
|
86
|
+
const variables = value.variables_schema && typeof value.variables_schema === "object" && !Array.isArray(value.variables_schema) ? value.variables_schema : {};
|
|
87
|
+
const setBody = (body: string) => {
|
|
88
|
+
const next = { ...variables };
|
|
89
|
+
for (const match of body.matchAll(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu)) if (!(match[1]! in next)) next[match[1]!] = { type: "string" };
|
|
90
|
+
onChange({ ...value, body_template: body, variables_schema: next });
|
|
91
|
+
};
|
|
92
|
+
return <div className="configuration-builder"><div className="form-grid">
|
|
93
|
+
<label>Send to<select disabled={disabled} value={string(value.audience)} onChange={event => onChange({ ...value, audience: event.currentTarget.value })}><option value="reporter">Person who reported</option><option value="affected_user">Person affected by the decision</option></select></label>
|
|
94
|
+
<label>When to use<select disabled={disabled} value={string(value.purpose)} onChange={event => onChange({ ...value, purpose: event.currentTarget.value })}><option value="follow_up">Request more information</option><option value="outcome">Share the case outcome</option><option value="affected_user_notice">Explain an action taken</option><option value="appeal_outcome">Share the appeal outcome</option></select></label>
|
|
95
|
+
</div><label>Language<small>Language code, such as en, fr, or en-GB.</small><input required pattern="[a-z]{2,3}(?:-[A-Z]{2})?" maxLength={6} disabled={disabled} value={string(value.locale)} onChange={event => onChange({ ...value, locale: event.currentTarget.value })} /></label>
|
|
96
|
+
<label>Message<textarea required rows={6} maxLength={10000} disabled={disabled} value={string(value.body_template)} onChange={event => setBody(event.currentTarget.value)} /></label>
|
|
97
|
+
<div className="message-variable-buttons"><span>Insert a detail:</span>{[...new Set(["report_reference", ...Object.keys(variables)])].map(name => <button className="secondary" type="button" disabled={disabled} key={name} onClick={() => setBody(`${string(value.body_template)} {{${name}}}`)}>{name.replaceAll("_", " ")}</button>)}</div>
|
|
98
|
+
<label className="checkbox"><input type="checkbox" disabled={disabled} checked={value.automation_allowed === true} onChange={event => onChange({ ...value, automation_allowed: event.currentTarget.checked })} /><span>Allow this template in automated messages</span></label>
|
|
99
|
+
<section className="message-preview" aria-label="Message preview"><h3>Preview</h3><p>{string(value.body_template).replace(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu, (_, name: string) => name === "report_reference" ? "RPT-EXAMPLE" : `[${name.replaceAll("_", " ")}]`) || "Your message will appear here."}</p><small>Example details are replaced with the recipient’s case details when sent.</small></section>
|
|
100
|
+
</div>;
|
|
101
|
+
}
|
|
@@ -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; } }
|