feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
+22
-7
@@ -59,11 +59,14 @@ export type CaseRecord = {
|
||||
};
|
||||
|
||||
export type CaseGrant = {
|
||||
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
|
||||
subject_kind: "account" | "identity" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
|
||||
subject_id: string;
|
||||
permissions: Array<"read" | "update" | "share" | "admin">;
|
||||
allowed_purposes: string[];
|
||||
};
|
||||
|
||||
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
|
||||
|
||||
export type CaseStatusDefinition = {
|
||||
status_key: string;
|
||||
label: string;
|
||||
@@ -101,6 +104,7 @@ export type CaseTimelineEntry = {
|
||||
event_type: string;
|
||||
case_revision: number;
|
||||
summary: string;
|
||||
purpose: string;
|
||||
actor_id?: string | null;
|
||||
occurred_at: string;
|
||||
audit_event_id?: string | null;
|
||||
@@ -140,6 +144,7 @@ export function listCases(
|
||||
query?: string;
|
||||
statuses?: string[];
|
||||
caseTypes?: string[];
|
||||
purpose?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
},
|
||||
@@ -149,6 +154,7 @@ export function listCases(
|
||||
query: options.query,
|
||||
status_key: options.statuses,
|
||||
case_type_key: options.caseTypes,
|
||||
purpose: options.purpose,
|
||||
offset: options.offset,
|
||||
limit: options.limit
|
||||
}), { signal });
|
||||
@@ -157,9 +163,10 @@ export function listCases(
|
||||
export function getCase(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
|
||||
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function listCaseCatalog(
|
||||
@@ -172,27 +179,30 @@ export function listCaseCatalog(
|
||||
export function caseHistory(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ revisions: CaseRecord[] }> {
|
||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
|
||||
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function caseTimeline(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ entries: CaseTimelineEntry[] }> {
|
||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
||||
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
|
||||
}
|
||||
|
||||
export function listCaseDecisions(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
purpose: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ decisions: FormalDecision[] }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
@@ -209,6 +219,7 @@ export function recordCaseDecision(
|
||||
conditions: string[];
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: string;
|
||||
}
|
||||
): Promise<CaseDecisionResult> {
|
||||
return apiFetch(
|
||||
@@ -229,6 +240,7 @@ export function updateCase(
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: string;
|
||||
title?: string;
|
||||
status_key?: string;
|
||||
access_mode?: "tenant" | "restricted";
|
||||
@@ -249,6 +261,7 @@ export function linkCaseEvidence(
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: string;
|
||||
reference: {
|
||||
owner_module: string;
|
||||
kind: string;
|
||||
@@ -275,6 +288,7 @@ export function unlinkCaseEvidence(
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
purpose: string;
|
||||
}
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(
|
||||
@@ -287,11 +301,12 @@ export function unlinkCaseEvidence(
|
||||
export function caseShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
targetType: "user" | "group"
|
||||
targetType: "user" | "group",
|
||||
purpose: string
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
|
||||
{ target_type: targetType }
|
||||
{ target_type: targetType, purpose }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
||||
export default function CaseDecisionDialog({
|
||||
settings,
|
||||
record,
|
||||
purpose,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
purpose: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (result: CaseDecisionResult) => void;
|
||||
@@ -82,7 +84,8 @@ export default function CaseDecisionDialog({
|
||||
reasoning: reasoning.trim(),
|
||||
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current
|
||||
idempotency_key: idempotencyKey.current,
|
||||
purpose
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
onSaved(result);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
@@ -22,6 +22,7 @@ import { ActionToolbar,
|
||||
type QuickAccessResult
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
caseHistory,
|
||||
caseTimeline,
|
||||
getCase,
|
||||
@@ -48,6 +49,8 @@ import {
|
||||
|
||||
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
||||
const { caseId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||
const navigate = useGuardedNavigate();
|
||||
const [record, setRecord] = useState<CaseRecord | null>(null);
|
||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||
@@ -92,20 +95,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
tenantId: record.reference.tenant_id,
|
||||
label: `${record.case_number} · ${record.title}`,
|
||||
version: record.reference.version ?? String(record.revision),
|
||||
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
|
||||
} : null, [record]);
|
||||
path: `/cases/${encodeURIComponent(record.reference.object_id)}?purpose=${encodeURIComponent(accessPurpose)}`
|
||||
} : null, [accessPurpose, record]);
|
||||
useRegisterActiveObject(activeCase);
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return Promise.all([
|
||||
getCase(settings, caseId, signal),
|
||||
getCase(settings, caseId, accessPurpose, signal),
|
||||
listCaseCatalog(settings, signal),
|
||||
caseHistory(settings, caseId, signal),
|
||||
caseTimeline(settings, caseId, signal),
|
||||
caseHistory(settings, caseId, accessPurpose, signal),
|
||||
caseTimeline(settings, caseId, accessPurpose, signal),
|
||||
canReadDecisions
|
||||
? listCaseDecisions(settings, caseId, signal)
|
||||
? listCaseDecisions(settings, caseId, accessPurpose, signal)
|
||||
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
||||
]).
|
||||
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
||||
@@ -119,7 +122,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
setChangeReason("");
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
}, [canReadDecisions, caseId, settings]);
|
||||
}, [accessPurpose, canReadDecisions, caseId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -179,6 +182,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
purpose: accessPurpose,
|
||||
reference: {
|
||||
owner_module: reference.ownerModule,
|
||||
kind: reference.kind,
|
||||
@@ -203,7 +207,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
QUICK_ACCESS_RESULT_EVENT,
|
||||
receiveReference
|
||||
);
|
||||
}, [canUpdate, caseId, draftDirty, load, record, settings]);
|
||||
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
|
||||
|
||||
function discardDraft() {
|
||||
if (!record) return;
|
||||
@@ -222,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
purpose: accessPurpose,
|
||||
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
||||
...(status !== record.status_key ? { status_key: status } : {})
|
||||
});
|
||||
@@ -260,7 +265,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
purpose: accessPurpose
|
||||
}
|
||||
);
|
||||
setRecord(saved);
|
||||
@@ -296,7 +302,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<main className="cases-page">
|
||||
<div className="case-detail-shell">
|
||||
<ActionToolbar className="case-detail-toolbar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
Cases
|
||||
</button>
|
||||
@@ -325,6 +331,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
icon={<Share2 size={16} />}
|
||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||
onClick={() => setShareOpen(true)}
|
||||
helpContextId="cases.detail.access"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -404,6 +411,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
|
||||
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
|
||||
<Fact label="Revision" value={String(record.revision)} />
|
||||
<Fact label="Access purpose" value={accessPurpose} />
|
||||
<Fact label="Access mode" value={humanize(record.access_mode)} />
|
||||
<Fact label="Last change" value={record.change_reason} />
|
||||
</div>
|
||||
|
||||
@@ -444,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<li key={entry.event_id}>
|
||||
<strong>{humanize(entry.event_type)}</strong>
|
||||
<span>{entry.summary}</span>
|
||||
<span>{entry.purpose}</span>
|
||||
<time>{formatDateTime(entry.occurred_at)}</time>
|
||||
</li>
|
||||
)}
|
||||
@@ -470,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<CaseShareDialog
|
||||
settings={settings}
|
||||
record={record}
|
||||
purpose={accessPurpose}
|
||||
open={shareOpen}
|
||||
onClose={() => setShareOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
@@ -484,6 +495,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
<CaseDecisionDialog
|
||||
settings={settings}
|
||||
record={record}
|
||||
purpose={accessPurpose}
|
||||
open={decisionOpen}
|
||||
onClose={() => setDecisionOpen(false)}
|
||||
onSaved={(result) => {
|
||||
|
||||
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
|
||||
export default function CaseShareDialog({
|
||||
settings,
|
||||
record,
|
||||
purpose,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
purpose: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (record: CaseRecord) => void;
|
||||
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
|
||||
const [targetType, setTargetType] = useState<TargetType>("user");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [permission, setPermission] = useState<Permission>("read");
|
||||
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const targetProvider = useMemo(
|
||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
|
||||
[record.reference.object_id, settings, targetType]
|
||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
|
||||
[purpose, record.reference.object_id, settings, targetType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setAllowedPurposes(purpose);
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
setConfirmOpen(false);
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
}, [open, record]);
|
||||
}, [open, purpose, record]);
|
||||
|
||||
const changed = restricted !== (record.access_mode === "restricted")
|
||||
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
||||
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
setPermission("read");
|
||||
setAllowedPurposes(purpose);
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addGrant() {
|
||||
const subjectId = targetId.trim();
|
||||
if (!subjectId) return;
|
||||
const purposes = splitPurposes(allowedPurposes);
|
||||
if (!subjectId || purposes.length === 0) return;
|
||||
const subjectKind = targetType === "user" ? "account" : "group";
|
||||
setGrants((current) => [
|
||||
...current.filter(
|
||||
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
|
||||
{
|
||||
subject_kind: subjectKind,
|
||||
subject_id: subjectId,
|
||||
permissions: [permission]
|
||||
permissions: [permission],
|
||||
allowed_purposes: purposes
|
||||
}
|
||||
]);
|
||||
setTargetId("");
|
||||
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current,
|
||||
purpose,
|
||||
access_mode: restricted ? "restricted" : "tenant",
|
||||
access_grants: grants
|
||||
});
|
||||
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="case-share-dialog"
|
||||
helpContextId="cases.detail.access"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={close}>Cancel</Button>
|
||||
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
|
||||
variant="primary"
|
||||
disabledReason={saveDisabledReason}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
helpContextId="cases.detail.access"
|
||||
>
|
||||
{busy ? "Saving" : "Save access"}
|
||||
</Button>
|
||||
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
|
||||
onChange={setRestricted}
|
||||
/>
|
||||
<p className="case-share-explanation">
|
||||
Tenant cases follow the Cases read permission. Restricted cases are visible only to
|
||||
their creator, case administrators, assigned functions or units, and the explicit
|
||||
grants below.
|
||||
Tenant cases follow the Cases read permission. Restricted cases require a current
|
||||
grant whose permission and exact allowed purpose both match the attempted action.
|
||||
The creating account receives a purpose-bound custodian grant.
|
||||
</p>
|
||||
|
||||
<div className="case-share-add-row">
|
||||
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
|
||||
<option value="admin">Administer</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={allowedPurposes}
|
||||
disabled={busy}
|
||||
onChange={(event) => setAllowedPurposes(event.target.value)}
|
||||
placeholder="cases.casework, cases.search"
|
||||
/>
|
||||
</FormField>
|
||||
<IconButton
|
||||
label="Add access grant"
|
||||
icon={<Plus size={16} />}
|
||||
variant="primary"
|
||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
|
||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
|
||||
onClick={addGrant}
|
||||
/>
|
||||
</div>
|
||||
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
|
||||
<option value="share">Share</option>
|
||||
<option value="admin">Administer</option>
|
||||
</select>
|
||||
<input
|
||||
aria-label={`Allowed purposes for ${grant.subject_id}`}
|
||||
value={grant.allowed_purposes.join(", ")}
|
||||
disabled={busy}
|
||||
onChange={(event) => setGrants((current) => current.map((item) =>
|
||||
item === grant
|
||||
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
|
||||
: item
|
||||
))}
|
||||
/>
|
||||
<IconButton
|
||||
label={`Remove access for ${grant.subject_id}`}
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
helpContextId="cases.detail.access"
|
||||
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
|
||||
/>
|
||||
</div>
|
||||
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function splitPurposes(value: string): string[] {
|
||||
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -18,6 +19,7 @@ import { Button,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||
listCaseCatalog,
|
||||
listCases,
|
||||
type CaseCatalog,
|
||||
@@ -28,8 +30,12 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
|
||||
|
||||
export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [purpose, setPurpose] = useState(initialPurpose);
|
||||
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
|
||||
const [status, setStatus] = useState("");
|
||||
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||
@@ -46,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
listCases(settings, {
|
||||
query: submittedQuery,
|
||||
statuses: status ? [status] : undefined,
|
||||
purpose: submittedPurpose,
|
||||
limit: 200
|
||||
}, controller.signal),
|
||||
listCaseCatalog(settings, controller.signal)
|
||||
@@ -62,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [reloadKey, settings, status, submittedQuery]);
|
||||
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
|
||||
|
||||
const statusLabels = useMemo(
|
||||
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
||||
@@ -76,6 +83,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedQuery(query.trim());
|
||||
setSubmittedPurpose(purpose.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -96,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
aria-label="Search cases"
|
||||
placeholder="Search cases"
|
||||
/>
|
||||
<input
|
||||
value={purpose}
|
||||
onChange={(event) => setPurpose(event.target.value)}
|
||||
aria-label="Case access purpose"
|
||||
placeholder="Case access purpose"
|
||||
data-help-context-id="cases.field.access-purpose"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -135,7 +151,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||
<SelectionListItem
|
||||
selected={false}
|
||||
key={item.reference.object_id}
|
||||
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
|
||||
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
|
||||
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
|
||||
<StatusBadge
|
||||
status={statusDefinition?.terminal ? "inactive" : "active"}
|
||||
|
||||
@@ -81,6 +81,12 @@ const en = {
|
||||
"Permission": "Permission",
|
||||
"Save access": "Save access",
|
||||
"No explicit access grants.": "No explicit access grants.",
|
||||
"Case access purpose": "Case access purpose",
|
||||
"Access purpose": "Access purpose",
|
||||
"Access mode": "Access mode",
|
||||
"Allowed purposes": "Allowed purposes",
|
||||
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
|
||||
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
|
||||
"Record formal decision": "Record formal decision",
|
||||
"File Case in eAkte": "File Case in eAkte",
|
||||
"File Decision in eAkte": "File Decision in eAkte",
|
||||
@@ -184,6 +190,12 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Permission": "Berechtigung",
|
||||
"Save access": "Zugriff speichern",
|
||||
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
||||
"Case access purpose": "Zugriffszweck für Vorgänge",
|
||||
"Access purpose": "Zugriffszweck",
|
||||
"Access mode": "Zugriffsmodus",
|
||||
"Allowed purposes": "Erlaubte Zwecke",
|
||||
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
|
||||
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
|
||||
"Record formal decision": "Formelle Entscheidung erfassen",
|
||||
"File Case in eAkte": "Vorgang in eAkte verakten",
|
||||
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
||||
|
||||
Reference in New Issue
Block a user