Migrate Cases interface patterns
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import { ArrowLeft, Save, Share2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
useGuardedNavigate,
|
||||
useUnsavedDraftGuard,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -24,6 +28,11 @@ import {
|
||||
type InstitutionalReference
|
||||
} from "../../api/cases";
|
||||
import CaseShareDialog from "./CaseShareDialog";
|
||||
import {
|
||||
CASES_DOCUMENTATION,
|
||||
CASES_FIELDS_DOCUMENTATION,
|
||||
CASES_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
|
||||
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
||||
@@ -40,6 +49,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const canUpdate = hasScope(auth, "cases:case:update");
|
||||
const canClose = hasScope(auth, "cases:case:close");
|
||||
const canShare = hasScope(auth, "cases:case:share");
|
||||
@@ -84,28 +94,65 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
);
|
||||
}, [canClose, catalog, record]);
|
||||
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
|
||||
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
|
||||
|
||||
async function save() {
|
||||
if (!record || !changed || !changeReason.trim()) return;
|
||||
function discardDraft() {
|
||||
if (!record) return;
|
||||
setTitle(record.title);
|
||||
setStatus(record.status_key);
|
||||
setChangeReason("");
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!record || !changed || !title.trim() || !changeReason.trim()) return false;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateCase(settings, caseId, {
|
||||
const saved = await updateCase(settings, caseId, {
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
||||
...(status !== record.status_key ? { status_key: status } : {})
|
||||
});
|
||||
await load();
|
||||
setRecord(saved);
|
||||
setTitle(saved.title);
|
||||
setStatus(saved.status_key);
|
||||
setChangeReason("");
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
try {
|
||||
await load();
|
||||
} catch (reloadError) {
|
||||
setError(reloadError instanceof Error
|
||||
? `The case was saved, but its history could not be refreshed. ${reloadError.message}`
|
||||
: "The case was saved, but its history could not be refreshed.");
|
||||
}
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
title: "i18n:govoplan-cases.unsaved_title",
|
||||
message: "i18n:govoplan-cases.unsaved_message",
|
||||
onSave: save,
|
||||
onDiscard: discardDraft
|
||||
});
|
||||
|
||||
const saveDisabledReason = saving
|
||||
? CASES_I18N.saving
|
||||
: !changed
|
||||
? CASES_I18N.noChanges
|
||||
: !title.trim() || !changeReason.trim()
|
||||
? CASES_I18N.incomplete
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="cases-page">
|
||||
<div className="case-detail-shell">
|
||||
@@ -115,14 +162,14 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
Cases
|
||||
</button>
|
||||
{record && <span>{record.case_number}</span>}
|
||||
{record && canShare ? (
|
||||
<IconButton
|
||||
label="Manage case access"
|
||||
icon={<Share2 size={16} />}
|
||||
className="case-share-button"
|
||||
onClick={() => setShareOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
{record ? <IconButton
|
||||
label="Manage case access"
|
||||
icon={<Share2 size={16} />}
|
||||
className="case-share-button"
|
||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||
onClick={() => setShareOpen(true)}
|
||||
/> : null}
|
||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||
</div>
|
||||
<PageScrollViewport className="case-detail-viewport">
|
||||
{error &&
|
||||
@@ -142,26 +189,52 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
|
||||
</div>
|
||||
|
||||
{!canUpdate ? (
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "Case editing is read-only",
|
||||
details: CASES_I18N.updateReason,
|
||||
requiredAction: "Ask a case manager to make the required lifecycle change.",
|
||||
actor: "A user with the Cases update permission",
|
||||
target: "Case role or object access assignment"
|
||||
}}
|
||||
documentation={CASES_FIELDS_DOCUMENTATION}
|
||||
/>
|
||||
) : null}
|
||||
{canUpdate && !canClose ? (
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "Terminal case states are unavailable",
|
||||
details: CASES_I18N.closeReason,
|
||||
requiredAction: "Ask a case closer to complete the lifecycle transition.",
|
||||
actor: "A user with the Cases close permission",
|
||||
target: "Case role assignment"
|
||||
}}
|
||||
documentation={CASES_FIELDS_DOCUMENTATION}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canUpdate &&
|
||||
<div className="case-edit-panel">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
</FormField>
|
||||
<FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="case-change-reason">
|
||||
<span>Change reason</span>
|
||||
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
|
||||
</label>
|
||||
</FormField>
|
||||
<div className="case-change-reason">
|
||||
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!changed || !title.trim() || !changeReason.trim() || saving}
|
||||
onClick={save}>
|
||||
disabledReason={saveDisabledReason}
|
||||
onClick={() => void save()}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
{saving ? "Saving" : "Save"}
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
ReferenceSelect,
|
||||
ToggleSwitch,
|
||||
i18nMessage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -16,6 +21,10 @@ import {
|
||||
type CaseGrant,
|
||||
type CaseRecord
|
||||
} from "../../api/cases";
|
||||
import {
|
||||
CASES_FIELDS_DOCUMENTATION,
|
||||
CASES_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
|
||||
type TargetType = "user" | "group";
|
||||
@@ -42,6 +51,9 @@ export default function CaseShareDialog({
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const targetProvider = useMemo(
|
||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
|
||||
[record.reference.object_id, settings, targetType]
|
||||
@@ -51,14 +63,28 @@ export default function CaseShareDialog({
|
||||
if (!open) return;
|
||||
setRestricted(record.access_mode === "restricted");
|
||||
setGrants(record.access_grants);
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
setConfirmOpen(false);
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
}, [open, record]);
|
||||
|
||||
const changed = restricted !== (record.access_mode === "restricted")
|
||||
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
||||
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
|
||||
|
||||
function discardDraft() {
|
||||
setRestricted(record.access_mode === "restricted");
|
||||
setGrants(record.access_grants);
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addGrant() {
|
||||
const subjectId = targetId.trim();
|
||||
@@ -77,8 +103,8 @@ export default function CaseShareDialog({
|
||||
setTargetId("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!changed || !changeReason.trim()) return;
|
||||
async function save(): Promise<boolean> {
|
||||
if (!changed || !changeReason.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -86,34 +112,70 @@ export default function CaseShareDialog({
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
access_mode: restricted ? "restricted" : "tenant",
|
||||
access_grants: grants
|
||||
});
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
setRestricted(saved.access_mode === "restricted");
|
||||
setGrants(saved.access_grants);
|
||||
setTargetId("");
|
||||
setChangeReason("");
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: open && draftDirty,
|
||||
title: "i18n:govoplan-cases.unsaved_access_title",
|
||||
message: "i18n:govoplan-cases.unsaved_access_message",
|
||||
onSave: save,
|
||||
onDiscard: discardDraft
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (busy) return;
|
||||
if (draftDirty) requestDiscard(onClose);
|
||||
else onClose();
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const saveDisabledReason = busy
|
||||
? CASES_I18N.saving
|
||||
: !changed
|
||||
? CASES_I18N.noChanges
|
||||
: !changeReason.trim()
|
||||
? CASES_I18N.incomplete
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`Case access - ${record.case_number}`}
|
||||
onClose={onClose}
|
||||
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
|
||||
onClose={close}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="case-share-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={busy} onClick={close}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !changed || !changeReason.trim()}
|
||||
onClick={() => void save()}
|
||||
disabledReason={saveDisabledReason}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
{busy ? "Saving" : "Save access"}
|
||||
</Button>
|
||||
@@ -121,6 +183,7 @@ export default function CaseShareDialog({
|
||||
}
|
||||
>
|
||||
<div className="case-share-content">
|
||||
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
||||
) : null}
|
||||
@@ -139,7 +202,7 @@ export default function CaseShareDialog({
|
||||
</p>
|
||||
|
||||
<div className="case-share-add-row">
|
||||
<FormField label="Target type">
|
||||
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<select
|
||||
value={targetType}
|
||||
disabled={busy}
|
||||
@@ -152,7 +215,7 @@ export default function CaseShareDialog({
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Target">
|
||||
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<ReferenceSelect
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
@@ -162,7 +225,7 @@ export default function CaseShareDialog({
|
||||
aria-label={`Case access ${targetType}`}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Permission">
|
||||
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<select
|
||||
value={permission}
|
||||
disabled={busy}
|
||||
@@ -178,7 +241,7 @@ export default function CaseShareDialog({
|
||||
label="Add access grant"
|
||||
icon={<Plus size={16} />}
|
||||
variant="primary"
|
||||
disabled={busy || !targetId.trim()}
|
||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
|
||||
onClick={addGrant}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,7 +284,7 @@ export default function CaseShareDialog({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormField label="Change reason">
|
||||
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={changeReason}
|
||||
disabled={busy}
|
||||
@@ -232,6 +295,22 @@ export default function CaseShareDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="i18n:govoplan-cases.access_confirm_title"
|
||||
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
|
||||
value0: record.case_number,
|
||||
value1: restricted
|
||||
? "i18n:govoplan-cases.visibility_restricted"
|
||||
: "i18n:govoplan-cases.visibility_tenant",
|
||||
value2: grants.length
|
||||
})}
|
||||
confirmLabel="Save access"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => void confirmSave()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
i18nMessage,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
type CaseCatalog,
|
||||
type CaseRecord
|
||||
} from "../../api/cases";
|
||||
import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
|
||||
|
||||
|
||||
export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
@@ -79,7 +83,13 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
aria-label="Search cases"
|
||||
placeholder="Search cases"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">Search</button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabledReason={loading ? CASES_I18N.loading : undefined}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<label className="cases-status-filter">
|
||||
<span>Status</span>
|
||||
@@ -90,7 +100,8 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="cases-count">{total} cases</span>
|
||||
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
|
||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||
</div>
|
||||
<PageScrollViewport className="cases-list-viewport">
|
||||
{error &&
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const CASES_DOCUMENTATION = {
|
||||
topicId: "cases.institutional-context",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const CASES_FIELDS_DOCUMENTATION = {
|
||||
topicId: "cases.reference.lifecycle-access-and-evidence",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const CASES_I18N = {
|
||||
loading: "i18n:govoplan-cases.loading_reason",
|
||||
saving: "i18n:govoplan-cases.saving_reason",
|
||||
updateReason: "i18n:govoplan-cases.update_reason",
|
||||
closeReason: "i18n:govoplan-cases.close_reason",
|
||||
shareReason: "i18n:govoplan-cases.share_reason",
|
||||
noChanges: "i18n:govoplan-cases.no_changes_reason",
|
||||
incomplete: "i18n:govoplan-cases.incomplete_reason",
|
||||
targetRequired: "i18n:govoplan-cases.target_required_reason"
|
||||
} as const;
|
||||
Reference in New Issue
Block a user