feat: implement governed cases workspace

This commit is contained in:
2026-08-01 17:48:24 +02:00
parent 122159de16
commit c7821a5cb0
32 changed files with 5699 additions and 27 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/cases.css": "./src/styles/cases.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import {
apiFetch,
apiPath,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
export type InstitutionalReference = {
kind: string;
owner_module: string;
object_id: string;
tenant_id: string;
version?: string | null;
};
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
};
export type CaseRecord = {
reference: InstitutionalReference;
revision: number;
case_number: string;
case_type_key: string;
status_key: string;
title: string;
access_mode: "tenant" | "restricted";
access_grants: CaseGrant[];
context: Record<string, unknown>;
service_ref?: InstitutionalReference | null;
party_refs: InstitutionalReference[];
assignment_refs: InstitutionalReference[];
evidence_refs: EvidenceReference[];
decision_refs: InstitutionalReference[];
record_refs: InstitutionalReference[];
opened_at: string;
recorded_at: string;
deadline_at?: string | null;
closed_at?: string | null;
change_reason: string;
metadata: Record<string, unknown>;
};
export type CaseGrant = {
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
};
export type CaseStatusDefinition = {
status_key: string;
label: string;
category: string;
terminal: boolean;
sort_order: number;
active: boolean;
revision: number;
};
export type CaseTypeDefinition = {
type_key: string;
label: string;
description?: string | null;
initial_status_key: string;
allowed_status_keys: string[];
active: boolean;
revision: number;
};
export type CaseCatalog = {
statuses: CaseStatusDefinition[];
types: CaseTypeDefinition[];
};
export type CaseListResponse = {
cases: CaseRecord[];
total: number;
offset: number;
limit: number;
};
export type CaseTimelineEntry = {
event_id: string;
event_type: string;
case_revision: number;
summary: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
payload: Record<string, unknown>;
};
export function listCases(
settings: ApiSettings,
options: {
query?: string;
statuses?: string[];
caseTypes?: string[];
offset?: number;
limit?: number;
},
signal?: AbortSignal
): Promise<CaseListResponse> {
return apiFetch<CaseListResponse>(settings, apiPath("/api/v1/cases", {
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
offset: options.offset,
limit: options.limit
}), { signal });
}
export function getCase(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
}
export function listCaseCatalog(
settings: ApiSettings,
signal?: AbortSignal
): Promise<CaseCatalog> {
return apiFetch<CaseCatalog>(settings, "/api/v1/cases/catalog", { signal });
}
export function caseHistory(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
}
export function updateCase(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
access_grants?: CaseGrant[];
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType }
);
}
+259
View File
@@ -0,0 +1,259 @@
import { ArrowLeft, Save, Share2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
listCaseCatalog,
updateCase,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [history, setHistory] = useState<CaseRecord[]>([]);
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
const [title, setTitle] = useState("");
const [status, setStatus] = useState("");
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal)
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [caseId, settings]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Case could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
const statuses = useMemo(() => {
const type = catalog.types.find((item) => item.type_key === record?.case_type_key);
const allowed = new Set(type?.allowed_status_keys ?? []);
return catalog.statuses.filter((item) =>
(allowed.size === 0 || allowed.has(item.status_key))
&& (canClose || !item.terminal || item.status_key === record?.status_key)
);
}, [canClose, catalog, record]);
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
async function save() {
if (!record || !changed || !changeReason.trim()) return;
setSaving(true);
setError("");
try {
await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
} finally {
setSaving(false);
}
}
return (
<main className="cases-page">
<div className="case-detail-shell">
<div className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<ArrowLeft size={16} aria-hidden="true" />
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}
</div>
<PageScrollViewport className="case-detail-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading case" />}
{!loading && record &&
<div className="case-detail-content">
<section className="case-detail-main">
<div className="case-detail-title-row">
<div>
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
<h1>{record.title}</h1>
</div>
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div>
{canUpdate &&
<div className="case-edit-panel">
<label>
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label>
<span>Status</span>
<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>
<Button
variant="primary"
disabled={!changed || !title.trim() || !changeReason.trim() || saving}
onClick={save}>
<Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"}
</Button>
</div>
}
<div className="case-facts">
<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="Last change" value={record.change_reason} />
</div>
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<ReferenceSection title="Decisions" references={record.decision_refs} />
<ReferenceSection title="Records" references={record.record_refs} />
</section>
<aside className="case-detail-aside">
<section>
<h2>Timeline</h2>
<ol className="case-timeline">
{timeline.map((entry) =>
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>History</h2>
<div className="case-history-list">
{history.map((revision) =>
<div key={revision.revision}>
<strong>Revision {revision.revision}</strong>
<span>{revision.change_reason}</span>
<time>{formatDateTime(revision.recorded_at)}</time>
</div>
)}
</div>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
{record ? (
<CaseShareDialog
settings={settings}
record={record}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
setRecord(saved);
void load().catch((reason) => {
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
});
}}
/>
) : null}
</main>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) {
if (references.length === 0) return null;
return (
<section className="case-reference-section">
<h2>{title}</h2>
<div className="case-reference-list">
{references.map((reference) =>
<span key={`${reference.owner_module}:${reference.object_id}:${reference.version ?? "current"}`}>
<strong>{humanize(reference.kind)}</strong>
{reference.object_id}
</span>
)}
</div>
</section>
);
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,240 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
ReferenceSelect,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
caseShareTargetProvider,
updateCase,
type CaseGrant,
type CaseRecord
} from "../../api/cases";
type TargetType = "user" | "group";
type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
}) {
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
);
useEffect(() => {
if (!open) return;
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
}, [open, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
(item) => !(item.subject_kind === subjectKind && item.subject_id === subjectId)
),
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
}
]);
setTargetId("");
}
async function save() {
if (!changed || !changeReason.trim()) return;
setBusy(true);
setError("");
try {
const saved = await updateCase(settings, record.reference.object_id, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
onSaved(saved);
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title={`Case access - ${record.case_number}`}
onClose={onClose}
closeDisabled={busy}
portal
className="case-share-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !changed || !changeReason.trim()}
onClick={() => void save()}
>
{busy ? "Saving" : "Save access"}
</Button>
</>
}
>
<div className="case-share-content">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
<ToggleSwitch
label="Case visibility"
inactiveLabel="Tenant"
activeLabel="Restricted"
checked={restricted}
disabled={busy}
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.
</p>
<div className="case-share-add-row">
<FormField label="Target type">
<select
value={targetType}
disabled={busy}
onChange={(event) => {
setTargetType(event.target.value as TargetType);
setTargetId("");
}}
>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</FormField>
<FormField label="Target">
<ReferenceSelect
value={targetId}
onChange={setTargetId}
provider={targetProvider}
disabled={busy}
placeholder={`Select a ${targetType}`}
aria-label={`Case access ${targetType}`}
/>
</FormField>
<FormField label="Permission">
<select
value={permission}
disabled={busy}
onChange={(event) => setPermission(event.target.value as Permission)}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabled={busy || !targetId.trim()}
onClick={addGrant}
/>
</div>
<div className="case-share-grants" aria-label="Explicit access grants">
{grants.length === 0 ? (
<p>No explicit access grants.</p>
) : grants.map((grant) => (
<div
key={`${grant.subject_kind}:${grant.subject_id}`}
className="case-share-grant"
>
<span>
<strong>{humanize(grant.subject_kind)}</strong>
{grant.subject_id}
</span>
<select
aria-label={`Permission for ${grant.subject_id}`}
value={grant.permissions[0] ?? "read"}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, permissions: [event.target.value as Permission] }
: item
))}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
))}
</div>
<FormField label="Change reason">
<input
value={changeReason}
disabled={busy}
maxLength={1000}
onChange={(event) => setChangeReason(event.target.value)}
placeholder="Why is case access changing?"
/>
</FormField>
</div>
</Dialog>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+143
View File
@@ -0,0 +1,143 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listCaseCatalog,
listCases,
type CaseCatalog,
type CaseRecord
} from "../../api/cases";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
Promise.all([
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
]).
then(([result, nextCatalog]) => {
setCases(result.cases);
setTotal(result.total);
setCatalog(nextCatalog);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Cases could not be loaded.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [settings, status, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
[catalog.statuses]
);
const typeLabels = useMemo(
() => new Map(catalog.types.map((item) => [item.type_key, item.label])),
[catalog.types]
);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
return (
<main className="cases-page">
<div className="cases-shell">
<div className="cases-toolbar">
<form className="cases-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search cases"
placeholder="Search cases"
/>
<button type="submit" className="btn btn-primary">Search</button>
</form>
<label className="cases-status-filter">
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">All statuses</option>
{catalog.statuses.map((item) =>
<option key={item.status_key} value={item.status_key}>{item.label}</option>
)}
</select>
</label>
<span className="cases-count">{total} cases</span>
</div>
<PageScrollViewport className="cases-list-viewport">
{error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading cases" />}
{!loading && !error && cases.length === 0 &&
<div className="cases-empty">No matching cases.</div>
}
{!loading && cases.length > 0 &&
<div className="cases-list" role="list">
{cases.map((item) => {
const statusDefinition = statusLabels.get(item.status_key);
return (
<button
type="button"
className="case-list-row"
role="listitem"
key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
<div className="case-list-primary">
<strong>{item.title}</strong>
<span>{item.case_number}</span>
</div>
<span>{typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)}</span>
<span>{formatDate(item.deadline_at)}</span>
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
label={statusDefinition?.label ?? humanize(item.status_key)}
/>
</button>
);
})}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function formatDate(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "No deadline";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, casesModule } from "./module";
export * from "./api/cases";
+56
View File
@@ -0,0 +1,56 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/cases.css";
const CasesPage = lazy(() => import("./features/cases/CasesPage"));
const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = {
id: "cases",
label: "Cases",
version: "0.1.8",
optionalDependencies: [
"access",
"addresses",
"services",
"parties",
"mandates",
"decisions",
"forms_runtime",
"workflow_engine"
],
routes: [
{
path: "/cases",
anyOf: ["cases:case:read"],
order: 35,
surfaceId: "cases.list",
render: (context) => createElement(CasesPage, context)
},
{
path: "/cases/:caseId",
anyOf: ["cases:case:read"],
order: 36,
surfaceId: "cases.detail",
render: (context) => createElement(CaseDetailPage, context)
}
],
navItems: [
{
to: "/cases",
label: "Cases",
iconName: "briefcase-business",
anyOf: ["cases:case:read"],
order: 35,
surfaceId: "cases.navigation"
}
],
viewSurfaces: [
{ id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "Cases navigation", order: 10 },
{ id: "cases.list", moduleId: "cases", kind: "route", label: "Case list", order: 20 },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details", order: 30 }
]
};
export default casesModule;
+354
View File
@@ -0,0 +1,354 @@
.cases-page,
.cases-shell,
.case-detail-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.cases-shell,
.case-detail-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.cases-toolbar,
.case-detail-toolbar {
display: flex;
align-items: center;
gap: 14px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.cases-search {
display: flex;
align-items: center;
gap: 8px;
width: min(560px, 100%);
}
.cases-search input {
min-width: 120px;
flex: 1;
}
.cases-status-filter {
display: flex;
align-items: center;
gap: 7px;
}
.cases-status-filter > span,
.cases-count {
color: var(--text-soft);
font-size: 0.82rem;
}
.cases-count {
margin-left: auto;
}
.case-share-button {
margin-left: auto;
}
.cases-list-viewport,
.case-detail-viewport {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.cases-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.case-list-row {
display: grid;
grid-template-columns: minmax(260px, 2fr) minmax(150px, 1fr) minmax(150px, 0.8fr) auto;
align-items: center;
gap: 16px;
width: 100%;
min-height: 66px;
padding: 10px 14px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.case-list-row:last-child {
border-bottom: 0;
}
.case-list-row:hover,
.case-list-row:focus-visible {
background: var(--hover-bg);
}
.case-list-primary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.case-list-primary strong,
.case-list-primary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-list-primary span,
.case-list-row > span:not(.status-badge) {
color: var(--text-soft);
font-size: 0.84rem;
}
.case-detail-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 24px;
max-width: 1380px;
margin: 0 auto;
}
.case-detail-main,
.case-detail-aside {
min-width: 0;
}
.case-detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.case-detail-title-row h1 {
margin: 4px 0 0;
font-size: 1.45rem;
letter-spacing: 0;
}
.case-detail-eyebrow {
color: var(--text-soft);
font-size: 0.8rem;
}
.case-edit-panel {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(150px, 1fr);
gap: 10px;
margin-top: 16px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.case-edit-panel label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.case-edit-panel label > span {
color: var(--text-soft);
font-size: 0.8rem;
}
.case-change-reason {
grid-column: 1 / -1;
}
.case-edit-panel .btn {
justify-self: end;
}
.case-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin-top: 18px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.case-facts > div {
display: flex;
min-height: 66px;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--surface-raised);
}
.case-facts span,
.case-history-list time,
.case-timeline time {
color: var(--text-soft);
font-size: 0.78rem;
}
.case-reference-section,
.case-detail-aside section {
margin-top: 22px;
}
.case-reference-section h2,
.case-detail-aside h2 {
margin: 0 0 10px;
font-size: 0.95rem;
letter-spacing: 0;
}
.case-reference-list,
.case-history-list {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.case-reference-list > span,
.case-history-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--border);
}
.case-timeline {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--border);
}
.case-timeline li {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0 10px 14px;
border-bottom: 1px solid var(--border);
border-left: 2px solid var(--accent);
}
.cases-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.case-share-dialog {
width: min(860px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
}
.case-share-content {
display: flex;
min-height: 0;
flex-direction: column;
gap: 14px;
}
.case-share-explanation,
.case-share-grants > p {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.case-share-add-row {
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(220px, 1.7fr) minmax(130px, 0.8fr) auto;
align-items: end;
gap: 10px;
}
.case-share-add-row .icon-button {
width: 38px;
height: 38px;
}
.case-share-grants {
overflow: auto;
max-height: 280px;
border-top: 1px solid var(--border);
}
.case-share-grant {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(130px, 180px) auto;
align-items: center;
gap: 10px;
min-height: 50px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.case-share-grant > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-share-grant > span strong {
color: var(--text-soft);
font-size: 0.76rem;
}
@media (max-width: 860px) {
.cases-toolbar {
align-items: stretch;
flex-direction: column;
}
.cases-search {
width: 100%;
}
.cases-count {
margin-left: 0;
}
.case-list-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.case-list-row > span:not(.status-badge) {
display: none;
}
.case-detail-content {
grid-template-columns: 1fr;
}
.case-share-add-row {
grid-template-columns: 1fr;
}
.case-share-add-row .icon-button {
justify-self: end;
}
}