feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -21,6 +21,17 @@ export type EvidenceReference = {
|
||||
evidence_id: string;
|
||||
tenant_id: string;
|
||||
version?: string | null;
|
||||
source_ref?: string | null;
|
||||
derived_from?: string[];
|
||||
};
|
||||
|
||||
export type OwnerReference = {
|
||||
ownerModule: string;
|
||||
kind: string;
|
||||
objectId: string;
|
||||
tenantId: string;
|
||||
version?: string | null;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
export type CaseRecord = {
|
||||
@@ -230,6 +241,49 @@ export function updateCase(
|
||||
});
|
||||
}
|
||||
|
||||
export function linkCaseEvidence(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
reference: {
|
||||
owner_module: string;
|
||||
kind: string;
|
||||
object_id: string;
|
||||
tenant_id: string;
|
||||
version: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function unlinkCaseEvidence(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
evidenceId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
): Promise<CaseRecord> {
|
||||
return apiFetch<CaseRecord>(
|
||||
settings,
|
||||
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function caseShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
caseId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Archive, ArrowLeft, Save, Scale, Share2 } from "lucide-react";
|
||||
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 { ActionToolbar,
|
||||
@@ -11,23 +11,29 @@ import { ActionToolbar,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
QUICK_ACCESS_RESULT_EVENT,
|
||||
hasScope,
|
||||
usePlatformModuleInstalled,
|
||||
useGuardedNavigate,
|
||||
useRegisterActiveObject,
|
||||
useUnsavedDraftGuard,
|
||||
type PlatformRouteContext
|
||||
type PlatformRouteContext,
|
||||
type QuickAccessLaunchContext,
|
||||
type QuickAccessResult
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
caseHistory,
|
||||
caseTimeline,
|
||||
getCase,
|
||||
linkCaseEvidence,
|
||||
listCaseDecisions,
|
||||
listCaseCatalog,
|
||||
updateCase,
|
||||
unlinkCaseEvidence,
|
||||
type CaseCatalog,
|
||||
type CaseRecord,
|
||||
type CaseTimelineEntry,
|
||||
type EvidenceReference,
|
||||
type FormalDecision,
|
||||
type InstitutionalReference
|
||||
} from "../../api/cases";
|
||||
@@ -56,6 +62,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const [error, setError] = useState("");
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [decisionOpen, setDecisionOpen] = useState(false);
|
||||
const [linkingEvidence, setLinkingEvidence] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
const canUpdate = hasScope(auth, "cases:case:update");
|
||||
const canClose = hasScope(auth, "cases:case:close");
|
||||
@@ -64,6 +71,9 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
||||
const accessAvailable = usePlatformModuleInstalled("access");
|
||||
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||
const filesAvailable = usePlatformModuleInstalled("files");
|
||||
const mailAvailable = usePlatformModuleInstalled("mail");
|
||||
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
|
||||
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
|
||||
const decisionDisabledReason = !canUpdate
|
||||
? "Your account may not update this Case."
|
||||
@@ -132,6 +142,69 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
|
||||
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!record || !canUpdate) return;
|
||||
const receiveReference = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
launchContext?: QuickAccessLaunchContext;
|
||||
result?: QuickAccessResult;
|
||||
}>).detail;
|
||||
const sourceCase = detail?.launchContext?.activeObject;
|
||||
const result = detail?.result;
|
||||
if (
|
||||
!sourceCase
|
||||
|| sourceCase.ownerModule !== "cases"
|
||||
|| sourceCase.kind !== "case"
|
||||
|| sourceCase.objectId !== record.reference.object_id
|
||||
|| sourceCase.tenantId !== record.reference.tenant_id
|
||||
|| result?.outcome !== "completed"
|
||||
|| result.action !== "selected"
|
||||
|| !result.reference
|
||||
) return;
|
||||
if (draftDirty) {
|
||||
setError(
|
||||
"Save or discard the current Case edits before linking the selected reference."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reference = result.reference;
|
||||
if (!reference.version || !reference.path) {
|
||||
setError("The selected owner reference is not an exact versioned link.");
|
||||
return;
|
||||
}
|
||||
setLinkingEvidence(true);
|
||||
setError("");
|
||||
void linkCaseEvidence(settings, caseId, {
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
reference: {
|
||||
owner_module: reference.ownerModule,
|
||||
kind: reference.kind,
|
||||
object_id: reference.objectId,
|
||||
tenant_id: reference.tenantId,
|
||||
version: reference.version,
|
||||
path: reference.path
|
||||
}
|
||||
}).then((saved) => {
|
||||
setRecord(saved);
|
||||
setTitle(saved.title);
|
||||
setStatus(saved.status_key);
|
||||
return load();
|
||||
}).catch((reason) => {
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "The selected reference could not be linked to this Case.");
|
||||
}).finally(() => setLinkingEvidence(false));
|
||||
};
|
||||
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
|
||||
return () => window.removeEventListener(
|
||||
QUICK_ACCESS_RESULT_EVENT,
|
||||
receiveReference
|
||||
);
|
||||
}, [canUpdate, caseId, draftDirty, load, record, settings]);
|
||||
|
||||
function discardDraft() {
|
||||
if (!record) return;
|
||||
setTitle(record.title);
|
||||
@@ -173,6 +246,36 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
|
||||
if (!record || draftDirty || linkingEvidence) return;
|
||||
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
|
||||
setLinkingEvidence(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await unlinkCaseEvidence(
|
||||
settings,
|
||||
caseId,
|
||||
evidence.evidence_id,
|
||||
{
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
}
|
||||
);
|
||||
setRecord(saved);
|
||||
setTitle(saved.title);
|
||||
setStatus(saved.status_key);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "The owner reference could not be removed from this Case.");
|
||||
} finally {
|
||||
setLinkingEvidence(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
title: "i18n:govoplan-cases.unsaved_title",
|
||||
@@ -306,6 +409,16 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
||||
|
||||
<ReferenceSection title="Parties" references={record.party_refs} />
|
||||
<ReferenceSection title="Assignments" references={record.assignment_refs} />
|
||||
<EvidenceSection
|
||||
references={record.evidence_refs}
|
||||
providers={{
|
||||
files: filesAvailable,
|
||||
mail: mailAvailable,
|
||||
campaigns: campaignsAvailable
|
||||
}}
|
||||
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
|
||||
onUnlink={(evidence) => void unlinkEvidence(evidence)}
|
||||
/>
|
||||
{canReadDecisions ? (
|
||||
<DecisionSection
|
||||
decisions={decisions}
|
||||
@@ -410,6 +523,89 @@ function ReferenceSection({ title, references }: { title: string; references: In
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceSection({
|
||||
references,
|
||||
providers,
|
||||
canUnlink,
|
||||
onUnlink
|
||||
}: {
|
||||
references: EvidenceReference[];
|
||||
providers: Record<string, boolean>;
|
||||
canUnlink: boolean;
|
||||
onUnlink: (reference: EvidenceReference) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="case-reference-section case-evidence-section">
|
||||
<h2>Linked files, mail, and campaigns</h2>
|
||||
<p className="muted small-note">
|
||||
Select an authorized exact item in Quick Access to link it. Cases stores
|
||||
only the owner reference; the owning module checks access again when opened.
|
||||
</p>
|
||||
{references.length ? (
|
||||
<div className="case-reference-list">
|
||||
{references.map((reference) => {
|
||||
const source = linkedEvidenceSource(reference);
|
||||
const available = source
|
||||
? providers[source.owner_module] !== false
|
||||
: providers[reference.owner_module] !== false;
|
||||
return (
|
||||
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
|
||||
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
|
||||
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
|
||||
{source && available ? (
|
||||
<a href={source.path} className="btn btn-secondary">
|
||||
<ExternalLink size={15} aria-hidden="true" /> Open in owner
|
||||
</a>
|
||||
) : source ? (
|
||||
<small>Owner module unavailable; the reference remains in Case history.</small>
|
||||
) : null}
|
||||
{source ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!canUnlink}
|
||||
disabledReason={!canUnlink
|
||||
? "Save or discard Case edits before removing a link."
|
||||
: undefined}
|
||||
onClick={() => onUnlink(reference)}
|
||||
>
|
||||
<Unlink size={15} aria-hidden="true" /> Unlink
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">No owner references are linked.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function linkedEvidenceSource(reference: EvidenceReference): {
|
||||
owner_module: string;
|
||||
kind: string;
|
||||
path: string;
|
||||
} | null {
|
||||
if (!reference.source_ref) return null;
|
||||
try {
|
||||
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
|
||||
const owner = String(value.owner_module ?? "");
|
||||
const kind = String(value.kind ?? "");
|
||||
const path = String(value.path ?? "");
|
||||
if (
|
||||
value.contract !== "cases.quick-access-reference.v1"
|
||||
|| !owner
|
||||
|| !kind
|
||||
|| !path.startsWith("/")
|
||||
|| path.startsWith("//")
|
||||
) return null;
|
||||
return { owner_module: owner, kind, path };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DecisionSection({
|
||||
decisions,
|
||||
canFile,
|
||||
|
||||
@@ -176,6 +176,15 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.case-evidence-section .case-reference-list > span {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.case-evidence-section .case-reference-list small {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.case-timeline {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user