feat: implement governed cases workspace
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user