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