feat(records): govern lifecycle recovery and transfer evidence
This commit is contained in:
@@ -12,6 +12,12 @@ export type FilePlanNode = {
|
||||
valid_from?: string | null;
|
||||
valid_to?: string | null;
|
||||
recorded_at: string;
|
||||
closed_at?: string | null;
|
||||
retention_started_at?: string | null;
|
||||
retention_due_at?: string | null;
|
||||
retention_rule: Record<string, unknown>;
|
||||
appraisal_state?: string | null;
|
||||
appraisal: Record<string, unknown>;
|
||||
institutional_context: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -99,11 +105,63 @@ export type RecordChronology = {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RecordHold = {
|
||||
hold_id: string;
|
||||
record_id: string;
|
||||
revision: number;
|
||||
status: string;
|
||||
reason: string;
|
||||
authority: string;
|
||||
scope: Record<string, unknown>;
|
||||
effective_from: string;
|
||||
effective_to?: string | null;
|
||||
released_at?: string | null;
|
||||
policy_refs: string[];
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordDisposition = {
|
||||
disposition_id: string;
|
||||
record_id: string;
|
||||
revision: number;
|
||||
action: "retain" | "transfer" | "destroy" | "reclassify";
|
||||
status: string;
|
||||
reason: string;
|
||||
subject_revision: number;
|
||||
subject_sha256: string;
|
||||
consequence_preview: Record<string, unknown>;
|
||||
policy_refs: string[];
|
||||
approval_request_id?: string | null;
|
||||
proposed_by?: string | null;
|
||||
reviewed_by?: string | null;
|
||||
reviewed_at?: string | null;
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordTransferPackage = {
|
||||
package_id: string;
|
||||
record_id: string;
|
||||
disposition_id: string;
|
||||
revision: number;
|
||||
record_revision: number;
|
||||
provider_id: string;
|
||||
profile: string;
|
||||
status: string;
|
||||
manifest_sha256: string;
|
||||
receipt_sha256?: string | null;
|
||||
external_reference?: string | null;
|
||||
simulated: boolean;
|
||||
recorded_at: string;
|
||||
};
|
||||
|
||||
export type RecordDetail = {
|
||||
record: RecordEntry;
|
||||
volumes: RecordVolume[];
|
||||
items: RecordItem[];
|
||||
chronology: RecordChronology[];
|
||||
holds: RecordHold[];
|
||||
dispositions: RecordDisposition[];
|
||||
transfer_packages: RecordTransferPackage[];
|
||||
access_explanation: {
|
||||
decision: string;
|
||||
reason: string;
|
||||
@@ -120,6 +178,19 @@ export type RecordSourceProvider = {
|
||||
resource_types: string[];
|
||||
};
|
||||
|
||||
export type RecordArchiveProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
profiles: string[];
|
||||
authority_modes: string[];
|
||||
healthy: boolean;
|
||||
checked_at: string;
|
||||
last_success_at?: string | null;
|
||||
freshness_seconds?: number | null;
|
||||
limitations: string[];
|
||||
simulated: boolean;
|
||||
};
|
||||
|
||||
export function listRecords(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
@@ -154,6 +225,24 @@ export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): P
|
||||
return apiFetch(settings, "/api/v1/records/sources", { signal });
|
||||
}
|
||||
|
||||
export function getRecordArchiveProviders(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordArchiveProvider[] }> {
|
||||
return apiFetch(settings, "/api/v1/records/archive-providers", { signal });
|
||||
}
|
||||
|
||||
export function writeFilePlanNode(settings: ApiSettings, payload: Record<string, unknown>): Promise<FilePlanNode> {
|
||||
return apiFetch(settings, "/api/v1/records/catalog/file-plan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function writeRecordClass(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordClass> {
|
||||
return apiFetch(settings, "/api/v1/records/catalog/classes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRecord(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return apiFetch(settings, "/api/v1/records", {
|
||||
method: "POST",
|
||||
@@ -182,3 +271,99 @@ export function fileRecordItem(
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function createRecordVolume(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordVolume> {
|
||||
return recordMutation(settings, recordId, "volumes", payload);
|
||||
}
|
||||
|
||||
export function closeRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "close", payload);
|
||||
}
|
||||
|
||||
export function reopenRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "reopen", payload);
|
||||
}
|
||||
|
||||
export function appraiseRecord(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordEntry> {
|
||||
return recordMutation(settings, recordId, "appraise", payload);
|
||||
}
|
||||
|
||||
export function applyRecordHold(settings: ApiSettings, recordId: string, payload: Record<string, unknown>): Promise<RecordHold> {
|
||||
return recordMutation(settings, recordId, "holds", payload);
|
||||
}
|
||||
|
||||
export function releaseRecordHold(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
holdId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordHold> {
|
||||
return recordMutation(settings, recordId, `holds/${encodeURIComponent(holdId)}/release`, payload);
|
||||
}
|
||||
|
||||
export function proposeRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordDisposition> {
|
||||
return recordMutation(settings, recordId, "dispositions", payload);
|
||||
}
|
||||
|
||||
export function finalizeRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
dispositionId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<{ disposition: RecordDisposition; record: RecordEntry }> {
|
||||
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/finalize`, payload);
|
||||
}
|
||||
|
||||
export function withdrawRecordDisposition(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
dispositionId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordDisposition> {
|
||||
return recordMutation(settings, recordId, `dispositions/${encodeURIComponent(dispositionId)}/withdraw`, payload);
|
||||
}
|
||||
|
||||
export function prepareRecordTransfer(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordTransferPackage> {
|
||||
return recordMutation(settings, recordId, "transfer-packages", payload);
|
||||
}
|
||||
|
||||
export function dispatchRecordTransfer(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
packageId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<RecordTransferPackage> {
|
||||
return recordMutation(settings, recordId, `transfer-packages/${encodeURIComponent(packageId)}/dispatch`, payload);
|
||||
}
|
||||
|
||||
export function getRecordRecoveryStatus(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal });
|
||||
}
|
||||
|
||||
function recordMutation<T>(
|
||||
settings: ApiSettings,
|
||||
recordId: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/${path}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
writeFilePlanNode,
|
||||
writeRecordClass,
|
||||
type RecordCatalog
|
||||
} from "../../api/records";
|
||||
|
||||
|
||||
export function RecordCatalogDialog({
|
||||
open,
|
||||
settings,
|
||||
catalog,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
catalog: RecordCatalog;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<"file-plan" | "class">("file-plan");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [filePlanNodeId, setFilePlanNodeId] = useState("");
|
||||
const [retentionDays, setRetentionDays] = useState("");
|
||||
const [closureTrigger, setClosureTrigger] = useState("record_closed");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKind("file-plan");
|
||||
setSelectedId("");
|
||||
resetFields();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
resetFields();
|
||||
return;
|
||||
}
|
||||
if (kind === "file-plan") {
|
||||
const item = catalog.file_plan.find((candidate) => candidate.node_id === selectedId);
|
||||
if (!item) return;
|
||||
setIdentifier(item.node_id);
|
||||
setCode(item.code);
|
||||
setLabel(item.label);
|
||||
setDescription(item.description ?? "");
|
||||
setParentId(item.parent_node_id ?? "");
|
||||
} else {
|
||||
const item = catalog.classes.find((candidate) => candidate.class_id === selectedId);
|
||||
if (!item) return;
|
||||
setIdentifier(item.class_id);
|
||||
setCode(item.key);
|
||||
setLabel(item.label);
|
||||
setDescription(item.description ?? "");
|
||||
setFilePlanNodeId(item.file_plan_node_id);
|
||||
setRetentionDays(item.retention_period_days == null ? "" : String(item.retention_period_days));
|
||||
setClosureTrigger(item.closure_trigger ?? "record_closed");
|
||||
}
|
||||
}, [catalog.classes, catalog.file_plan, kind, selectedId]);
|
||||
|
||||
function resetFields() {
|
||||
setIdentifier("");
|
||||
setCode("");
|
||||
setLabel("");
|
||||
setDescription("");
|
||||
setParentId("");
|
||||
setFilePlanNodeId(catalog.file_plan[0]?.node_id ?? "");
|
||||
setRetentionDays("");
|
||||
setClosureTrigger("record_closed");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const current = kind === "file-plan"
|
||||
? catalog.file_plan.find((item) => item.node_id === selectedId)
|
||||
: catalog.classes.find((item) => item.class_id === selectedId);
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (kind === "file-plan") {
|
||||
await writeFilePlanNode(settings, {
|
||||
node_id: identifier.trim(),
|
||||
code: code.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || null,
|
||||
parent_node_id: parentId || null,
|
||||
active: true,
|
||||
recorded_at: new Date().toISOString(),
|
||||
expected_revision: current?.revision,
|
||||
idempotency_key: randomId(),
|
||||
institutional_context: {}
|
||||
});
|
||||
} else {
|
||||
await writeRecordClass(settings, {
|
||||
class_id: identifier.trim(),
|
||||
file_plan_node_id: filePlanNodeId,
|
||||
key: code.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || null,
|
||||
metadata_requirements: [],
|
||||
allowed_source_types: [],
|
||||
retention_period_days: retentionDays === "" ? null : Number(retentionDays),
|
||||
closure_trigger: closureTrigger,
|
||||
access_mode: "tenant",
|
||||
active: true,
|
||||
recorded_at: new Date().toISOString(),
|
||||
expected_revision: current?.revision,
|
||||
idempotency_key: randomId(),
|
||||
institutional_context: {}
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error && reason.message ? reason.message : "The Records catalog could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = identifier.trim() && code.trim() && label.trim() && (kind === "file-plan" || filePlanNodeId);
|
||||
const options = kind === "file-plan" ? catalog.file_plan : catalog.classes;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Administer Records catalog"
|
||||
onClose={onClose}
|
||||
closeDisabled={saving}
|
||||
portal
|
||||
className="records-dialog"
|
||||
helpContextId="records.catalog.admin"
|
||||
footer={<><Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="records-catalog-form" variant="primary" disabledReason={saving ? "The catalog revision is being saved." : !isValid ? "Complete all required catalog fields." : undefined}>{saving ? "Saving" : "Save revision"}</Button></>}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<form id="records-catalog-form" className="records-dialog-form" onSubmit={submit}>
|
||||
<FormField label="Catalog object">
|
||||
<select value={kind} onChange={(event) => { setKind(event.target.value as typeof kind); setSelectedId(""); }}>
|
||||
<option value="file-plan">File-plan node</option>
|
||||
<option value="class">Record class</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Revision target">
|
||||
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)}>
|
||||
<option value="">New {kind === "file-plan" ? "file-plan node" : "record class"}</option>
|
||||
{options.map((item) => <option key={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""} value={kind === "file-plan" ? "node_id" in item ? item.node_id : "" : "class_id" in item ? item.class_id : ""}>{item.label} · revision {item.revision}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={kind === "file-plan" ? "Node ID" : "Class ID"}><input value={identifier} onChange={(event) => setIdentifier(event.target.value)} disabled={Boolean(selectedId)} required /></FormField>
|
||||
<FormField label={kind === "file-plan" ? "Code" : "Key"}><input value={code} onChange={(event) => setCode(event.target.value)} required /></FormField>
|
||||
<FormField label="Label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>
|
||||
{kind === "file-plan" ? <FormField label="Parent node"><select value={parentId} onChange={(event) => setParentId(event.target.value)}><option value="">Top level</option>{catalog.file_plan.filter((item) => item.node_id !== identifier).map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField> : <><FormField label="File-plan node"><select value={filePlanNodeId} onChange={(event) => setFilePlanNodeId(event.target.value)} required>{catalog.file_plan.map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField><FormField label="Retention period (days)"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger"><select value={closureTrigger} onChange={(event) => setClosureTrigger(event.target.value)}><option value="record_closed">Record closure</option><option value="calendar_month_end">End of closure month</option><option value="calendar_year_end">End of closure year</option><option value="explicit">Explicit date at closure</option></select></FormField></>}
|
||||
<FormField label="Description"><textarea rows={4} value={description} onChange={(event) => setDescription(event.target.value)} /></FormField>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
ArchiveRestore,
|
||||
Ban,
|
||||
Box,
|
||||
CheckCheck,
|
||||
CirclePause,
|
||||
FolderPlus,
|
||||
LockKeyhole,
|
||||
Play,
|
||||
RotateCcw
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
StatusBadge,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
applyRecordHold,
|
||||
appraiseRecord,
|
||||
closeRecord,
|
||||
createRecordVolume,
|
||||
dispatchRecordTransfer,
|
||||
finalizeRecordDisposition,
|
||||
getRecordRecoveryStatus,
|
||||
prepareRecordTransfer,
|
||||
proposeRecordDisposition,
|
||||
releaseRecordHold,
|
||||
reopenRecord,
|
||||
withdrawRecordDisposition,
|
||||
type RecordArchiveProvider,
|
||||
type RecordDetail,
|
||||
type RecordDisposition,
|
||||
type RecordTransferPackage
|
||||
} from "../../api/records";
|
||||
|
||||
|
||||
type ActionKind =
|
||||
| "volume"
|
||||
| "close"
|
||||
| "reopen"
|
||||
| "appraise"
|
||||
| "hold"
|
||||
| "release-hold"
|
||||
| "disposition"
|
||||
| "finalize"
|
||||
| "withdraw"
|
||||
| "prepare-transfer"
|
||||
| "dispatch-transfer"
|
||||
| "recovery";
|
||||
|
||||
export function RecordLifecyclePanel({
|
||||
detail,
|
||||
archiveProviders,
|
||||
settings,
|
||||
canWrite,
|
||||
canAdmin,
|
||||
onChanged
|
||||
}: {
|
||||
detail: RecordDetail;
|
||||
archiveProviders: RecordArchiveProvider[];
|
||||
settings: PlatformRouteContext["settings"];
|
||||
canWrite: boolean;
|
||||
canAdmin: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<ActionKind | null>(null);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [purpose, setPurpose] = useState(detail.record.purpose);
|
||||
const [authority, setAuthority] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [outcome, setOutcome] = useState<"retain" | "transfer" | "destroy" | "reclassify">("retain");
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [retentionTriggerAt, setRetentionTriggerAt] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [recovery, setRecovery] = useState<Record<string, unknown> | null>(null);
|
||||
const record = detail.record;
|
||||
const currentDisposition = detail.dispositions.find((item) => item.status !== "withdrawn");
|
||||
const currentPackage = detail.transfer_packages[0];
|
||||
const activeHolds = detail.holds.filter((item) => item.status === "active");
|
||||
const healthyProviders = archiveProviders.filter((item) => item.healthy);
|
||||
const selectedProvider = healthyProviders.find((item) => item.id === providerId);
|
||||
|
||||
useEffect(() => setPurpose(record.purpose), [record.purpose]);
|
||||
useEffect(() => {
|
||||
if (selectedProvider?.profiles.includes(profile)) return;
|
||||
setProfile(selectedProvider?.profiles[0] ?? "");
|
||||
}, [profile, selectedProvider]);
|
||||
|
||||
const availableActions = useMemo(() => ({
|
||||
canClose: record.state === "open",
|
||||
canReopen: ["closed", "retention_running", "appraised"].includes(record.state) && !currentDisposition,
|
||||
canAppraise: ["closed", "retention_running"].includes(record.state),
|
||||
canPropose: record.state === "appraised" && !currentDisposition,
|
||||
canFinalize: currentDisposition?.status === "review_pending",
|
||||
canPrepare: currentDisposition?.status === "approved" && currentDisposition.action === "transfer" && !currentPackage,
|
||||
canDispatch: currentPackage?.status === "prepared"
|
||||
}), [currentDisposition, currentPackage, record.state]);
|
||||
|
||||
function openAction(kind: ActionKind, id = "") {
|
||||
setAction(kind);
|
||||
setTargetId(id);
|
||||
setReason("");
|
||||
setAuthority("");
|
||||
setLabel("");
|
||||
setOutcome((record.appraisal_state as typeof outcome | null) ?? "retain");
|
||||
const provider = healthyProviders[0];
|
||||
setProviderId(provider?.id ?? "");
|
||||
setProfile(provider?.profiles[0] ?? "");
|
||||
setRetentionTriggerAt("");
|
||||
setRecovery(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function openRecovery() {
|
||||
openAction("recovery");
|
||||
setSaving(true);
|
||||
try {
|
||||
setRecovery(await getRecordRecoveryStatus(settings, record.record_id));
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, "Recovery evidence could not be loaded."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!action || action === "recovery") return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const recordedAt = new Date().toISOString();
|
||||
const idempotencyKey = randomId();
|
||||
try {
|
||||
if (action === "volume") {
|
||||
await createRecordVolume(settings, record.record_id, {
|
||||
label: label.trim(),
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "close") {
|
||||
await closeRecord(settings, record.record_id, {
|
||||
expected_revision: record.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
retention_trigger_at: retentionTriggerAt ? new Date(retentionTriggerAt).toISOString() : null,
|
||||
restart_retention: false,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "reopen") {
|
||||
await reopenRecord(settings, record.record_id, lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey));
|
||||
} else if (action === "appraise") {
|
||||
await appraiseRecord(settings, record.record_id, {
|
||||
...lifecyclePayload(record.revision, purpose, reason, recordedAt, idempotencyKey),
|
||||
outcome,
|
||||
policy_refs: [],
|
||||
override_retention_not_due: canAdmin && record.retention_due_at != null && new Date(record.retention_due_at) > new Date()
|
||||
});
|
||||
} else if (action === "hold") {
|
||||
await applyRecordHold(settings, record.record_id, {
|
||||
expected_record_revision: record.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
authority: authority.trim(),
|
||||
scope: {},
|
||||
policy_refs: [],
|
||||
institutional_context: record.institutional_context,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "release-hold") {
|
||||
const hold = detail.holds.find((item) => item.hold_id === targetId);
|
||||
if (!hold) throw new Error("The selected hold is no longer available.");
|
||||
await releaseRecordHold(settings, record.record_id, hold.hold_id, {
|
||||
expected_hold_revision: hold.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "disposition") {
|
||||
await proposeRecordDisposition(settings, record.record_id, {
|
||||
expected_record_revision: record.revision,
|
||||
action: outcome,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
policy_refs: [],
|
||||
institutional_context: record.institutional_context,
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "finalize") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await finalizeRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
|
||||
expected_disposition_revision: currentDisposition.revision,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "withdraw") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await withdrawRecordDisposition(settings, record.record_id, currentDisposition.disposition_id, {
|
||||
expected_disposition_revision: currentDisposition.revision,
|
||||
purpose: purpose.trim(),
|
||||
reason: reason.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "prepare-transfer") {
|
||||
if (!currentDisposition) throw new Error("The disposition is no longer available.");
|
||||
await prepareRecordTransfer(settings, record.record_id, {
|
||||
disposition_id: currentDisposition.disposition_id,
|
||||
expected_record_revision: record.revision,
|
||||
provider_id: providerId,
|
||||
profile,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
} else if (action === "dispatch-transfer") {
|
||||
if (!currentPackage) throw new Error("The transfer package is no longer available.");
|
||||
await dispatchRecordTransfer(settings, record.record_id, currentPackage.package_id, {
|
||||
expected_package_revision: currentPackage.revision,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: recordedAt,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
}
|
||||
setAction(null);
|
||||
onChanged();
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, "The Records lifecycle action failed."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="records-detail-section" data-help-context-id="records.lifecycle">
|
||||
<div className="records-section-heading">
|
||||
<h2>Lifecycle</h2>
|
||||
<StatusBadge status={activeHolds.length > 0 ? "danger" : "active"} label={activeHolds.length > 0 ? `${activeHolds.length} active hold(s)` : humanize(record.state)} />
|
||||
</div>
|
||||
<div className="records-lifecycle-actions">
|
||||
<Button type="button" variant="ghost" onClick={() => openAction("volume")} disabledReason={!canWrite ? "Your account may not create record volumes." : undefined}>
|
||||
<FolderPlus size={16} aria-hidden="true" /> Add volume
|
||||
</Button>
|
||||
{availableActions.canClose && <Button type="button" variant="ghost" onClick={() => openAction("close")}><CirclePause size={16} aria-hidden="true" /> Close record</Button>}
|
||||
{availableActions.canReopen && <Button type="button" variant="ghost" onClick={() => openAction("reopen")}><RotateCcw size={16} aria-hidden="true" /> Reopen</Button>}
|
||||
{availableActions.canAppraise && <Button type="button" variant="ghost" onClick={() => openAction("appraise")}><CheckCheck size={16} aria-hidden="true" /> Appraise</Button>}
|
||||
<Button type="button" variant="ghost" onClick={() => openAction("hold")} disabledReason={!canWrite ? "Your account may not apply a hold." : undefined}><LockKeyhole size={16} aria-hidden="true" /> Apply hold</Button>
|
||||
{availableActions.canPropose && <Button type="button" variant="primary" onClick={() => openAction("disposition")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><ArchiveRestore size={16} aria-hidden="true" /> Propose disposition</Button>}
|
||||
{availableActions.canFinalize && <Button type="button" variant="primary" onClick={() => openAction("finalize")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><CheckCheck size={16} aria-hidden="true" /> Finalize approved disposition</Button>}
|
||||
{currentDisposition && ["review_pending", "review_unavailable"].includes(currentDisposition.status) && <Button type="button" variant="ghost" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>}
|
||||
{availableActions.canPrepare && <Button type="button" variant="primary" onClick={() => openAction("prepare-transfer")} disabledReason={healthyProviders.length === 0 ? "No healthy archive profile is available." : activeHolds.length ? "Release all active holds first." : undefined}><Box size={16} aria-hidden="true" /> Prepare transfer</Button>}
|
||||
{availableActions.canDispatch && <Button type="button" variant="primary" onClick={() => openAction("dispatch-transfer")} disabledReason={!currentPackage?.simulated ? "Real archive dispatch needs a target-specific recovery profile." : activeHolds.length ? "Release all active holds first." : undefined}><Play size={16} aria-hidden="true" /> Run simulation</Button>}
|
||||
{canAdmin && <Button type="button" variant="ghost" onClick={openRecovery}><ArchiveRestore size={16} aria-hidden="true" /> Recovery evidence</Button>}
|
||||
</div>
|
||||
|
||||
<LifecycleEvidence detail={detail} onRelease={(holdId) => openAction("release-hold", holdId)} canWrite={canWrite} />
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={action != null}
|
||||
title={dialogTitle(action)}
|
||||
onClose={() => !saving && setAction(null)}
|
||||
closeDisabled={saving}
|
||||
portal
|
||||
className="records-dialog"
|
||||
helpContextId={`records.lifecycle.${action ?? "action"}`}
|
||||
footer={action === "recovery" ? <Button type="button" variant="primary" onClick={() => setAction(null)}>Close</Button> : (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => setAction(null)} disabled={saving}>Cancel</Button>
|
||||
<Button type="submit" form="records-lifecycle-form" variant="primary" disabledReason={submitDisabledReason(action, { reason, purpose, authority, label, providerId, profile, saving })}>{saving ? "Saving" : actionLabel(action)}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{action === "recovery" ? <RecoveryResult value={recovery} loading={saving} /> : (
|
||||
<form id="records-lifecycle-form" className="records-dialog-form" onSubmit={submit}>
|
||||
{action === "volume" && <FormField label="Volume label" helpContextId="records.field.volume-label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>}
|
||||
{["appraise", "disposition"].includes(action ?? "") && <FormField label="Disposition" helpContextId="records.field.disposition"><select value={outcome} onChange={(event) => setOutcome(event.target.value as typeof outcome)}><option value="retain">Retain</option><option value="transfer">Transfer</option><option value="destroy">Destroy</option><option value="reclassify">Reclassify</option></select></FormField>}
|
||||
{action === "hold" && <FormField label="Authority" helpContextId="records.field.hold-authority"><input value={authority} onChange={(event) => setAuthority(event.target.value)} required /></FormField>}
|
||||
{action === "close" && <FormField label="Explicit retention trigger" helpContextId="records.field.retention-trigger"><input type="datetime-local" value={retentionTriggerAt} onChange={(event) => setRetentionTriggerAt(event.target.value)} /></FormField>}
|
||||
{action === "prepare-transfer" && <><FormField label="Archive provider" helpContextId="records.field.archive-provider"><select value={providerId} onChange={(event) => setProviderId(event.target.value)} required>{healthyProviders.map((item) => <option key={item.id} value={item.id}>{item.label}{item.simulated ? " (simulation)" : ""}</option>)}</select></FormField><FormField label="Transfer profile" helpContextId="records.field.archive-profile"><select value={profile} onChange={(event) => setProfile(event.target.value)} required>{selectedProvider?.profiles.map((item) => <option key={item} value={item}>{item}</option>)}</select></FormField></>}
|
||||
{action === "dispatch-transfer" && <DismissibleAlert tone="warning" dismissible={false} compact>This validates transfer handling only. Archival custody is not transferred.</DismissibleAlert>}
|
||||
{action === "finalize" && currentDisposition?.approval_request_id && <DismissibleAlert tone="info" dismissible={false} compact>Approval request <a href="/approvals">{currentDisposition.approval_request_id}</a> must be approved by another account.</DismissibleAlert>}
|
||||
{requiresReason(action) && <FormField label="Reason" helpContextId="records.field.lifecycle-reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={4} required /></FormField>}
|
||||
<FormField label="Purpose"><input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /></FormField>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDetail; onRelease: (holdId: string) => void; canWrite: boolean }) {
|
||||
const record = detail.record;
|
||||
return (
|
||||
<div className="records-lifecycle-evidence">
|
||||
<dl>
|
||||
<div><dt>Closed</dt><dd>{formatDateTime(record.closed_at)}</dd></div>
|
||||
<div><dt>Retention starts</dt><dd>{formatDateTime(record.retention_started_at)}</dd></div>
|
||||
<div><dt>Retention due</dt><dd>{formatDateTime(record.retention_due_at)}</dd></div>
|
||||
<div><dt>Appraisal</dt><dd>{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}</dd></div>
|
||||
</dl>
|
||||
{detail.volumes.length > 0 && <div className="records-evidence-list"><strong>Volumes</strong>{detail.volumes.map((volume) => <div key={volume.volume_id}><span>{volume.sequence}. {volume.label}</span><StatusBadge status={volume.state === "open" ? "active" : "neutral"} label={humanize(volume.state)} /></div>)}</div>}
|
||||
{detail.holds.length > 0 && <div className="records-evidence-list"><strong>Holds</strong>{detail.holds.map((hold) => <div key={hold.hold_id}><span><b>{hold.authority}</b> · {hold.reason}</span><span className="records-evidence-actions"><StatusBadge status={hold.status === "active" ? "danger" : "neutral"} label={humanize(hold.status)} />{hold.status === "active" && <Button type="button" variant="ghost" iconOnly aria-label="Release hold" title="Release hold" onClick={() => onRelease(hold.hold_id)} disabledReason={!canWrite ? "Your account may not release holds." : undefined}><Ban size={15} aria-hidden="true" /></Button>}</span></div>)}</div>}
|
||||
{detail.dispositions.map((item) => <DispositionEvidence key={item.disposition_id} item={item} />)}
|
||||
{detail.transfer_packages.map((item) => <TransferEvidence key={item.package_id} item={item} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DispositionEvidence({ item }: { item: RecordDisposition }) {
|
||||
return <div className="records-evidence-list"><strong>Disposition</strong><div><span><b>{humanize(item.action)}</b> · {item.reason}</span><StatusBadge status={item.status === "approved" ? "active" : item.status === "review_unavailable" ? "danger" : "warning"} label={humanize(item.status)} /></div>{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>;
|
||||
}
|
||||
|
||||
function TransferEvidence({ item }: { item: RecordTransferPackage }) {
|
||||
return <div className="records-evidence-list"><strong>Transfer package</strong><div><span><b>{item.profile}</b> · {item.manifest_sha256.slice(0, 12)}</span><StatusBadge status={item.status === "simulated_accepted" ? "warning" : item.status === "accepted" ? "active" : "neutral"} label={humanize(item.status)} /></div>{item.simulated && <small>Simulation only; no archival custody.</small>}</div>;
|
||||
}
|
||||
|
||||
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
|
||||
if (loading) return <p>Loading recovery evidence...</p>;
|
||||
if (!value) return null;
|
||||
const healthy = Boolean(value.healthy);
|
||||
return <div className="records-recovery-result"><DismissibleAlert tone={healthy ? "success" : "danger"} dismissible={false}>{healthy ? "Recovery evidence is complete." : `${String(value.failure_count)} recovery check(s) require attention.`}</DismissibleAlert><dl><div><dt>Evidence digest</dt><dd>{String(value.evidence_sha256)}</dd></div><div><dt>Source checks</dt><dd>{Array.isArray(value.source_checks) ? value.source_checks.length : 0}</dd></div><div><dt>Recovery operations</dt><dd>{Array.isArray(value.recovery_operations) ? value.recovery_operations.length : 0}</dd></div></dl></div>;
|
||||
}
|
||||
|
||||
function lifecyclePayload(expectedRevision: number, purpose: string, reason: string, recordedAt: string, idempotencyKey: string) {
|
||||
return { expected_revision: expectedRevision, purpose: purpose.trim(), reason: reason.trim(), recorded_at: recordedAt, idempotency_key: idempotencyKey };
|
||||
}
|
||||
|
||||
function requiresReason(action: ActionKind | null) {
|
||||
return action != null && ["close", "reopen", "appraise", "hold", "release-hold", "disposition", "withdraw"].includes(action);
|
||||
}
|
||||
|
||||
function dialogTitle(action: ActionKind | null) {
|
||||
if (action === "close") return "Close record";
|
||||
return action ? humanize(action) : "Records action";
|
||||
}
|
||||
|
||||
function actionLabel(action: ActionKind | null) {
|
||||
if (action === "dispatch-transfer") return "Run simulation";
|
||||
if (action === "finalize") return "Finalize";
|
||||
if (action === "close") return "Close record";
|
||||
return action ? humanize(action) : "Save";
|
||||
}
|
||||
|
||||
function submitDisabledReason(action: ActionKind | null, values: { reason: string; purpose: string; authority: string; label: string; providerId: string; profile: string; saving: boolean }) {
|
||||
if (values.saving) return "The lifecycle action is being saved.";
|
||||
if (!values.purpose.trim()) return "Enter the purpose for this action.";
|
||||
if (requiresReason(action) && !values.reason.trim()) return "Enter a reason for this action.";
|
||||
if (action === "hold" && !values.authority.trim()) return "Enter the hold authority.";
|
||||
if (action === "volume" && !values.label.trim()) return "Enter a volume label.";
|
||||
if (action === "prepare-transfer" && (!values.providerId || !values.profile)) return "Select an available archive profile.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
|
||||
}
|
||||
|
||||
function humanize(value: string) {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string) {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search
|
||||
Search,
|
||||
Settings2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -33,17 +34,21 @@ import {
|
||||
createRecord,
|
||||
fileRecordItem,
|
||||
getRecord,
|
||||
getRecordArchiveProviders,
|
||||
getRecordCatalog,
|
||||
getRecordSources,
|
||||
listRecords,
|
||||
updateRecord,
|
||||
type FilePlanNode,
|
||||
type RecordArchiveProvider,
|
||||
type RecordCatalog,
|
||||
type RecordClass,
|
||||
type RecordDetail,
|
||||
type RecordEntry,
|
||||
type RecordSourceProvider
|
||||
} from "../../api/records";
|
||||
import { RecordCatalogDialog } from "./RecordCatalogDialog";
|
||||
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
|
||||
import {
|
||||
RECORDS_DOCUMENTATION,
|
||||
RECORDS_FIELD_DOCUMENTATION
|
||||
@@ -68,6 +73,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
);
|
||||
const [detail, setDetail] = useState<RecordDetail | null>(null);
|
||||
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
|
||||
const [archiveProviders, setArchiveProviders] = useState<RecordArchiveProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -76,8 +82,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [fileOpen, setFileOpen] = useState(false);
|
||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||
const canWrite = auth.scopes.includes("records:workspace:write") ||
|
||||
auth.scopes.includes("records:workspace:admin");
|
||||
const canAdmin = auth.scopes.includes("records:workspace:admin");
|
||||
|
||||
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
||||
|
||||
@@ -88,11 +96,15 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
getRecordCatalog(settings, controller.signal),
|
||||
canWrite
|
||||
? getRecordSources(settings, controller.signal)
|
||||
: Promise.resolve({ providers: [] as RecordSourceProvider[] })
|
||||
: Promise.resolve({ providers: [] as RecordSourceProvider[] }),
|
||||
canWrite
|
||||
? getRecordArchiveProviders(settings, controller.signal)
|
||||
: Promise.resolve({ providers: [] as RecordArchiveProvider[] })
|
||||
]).
|
||||
then(([nextCatalog, sourceResult]) => {
|
||||
then(([nextCatalog, sourceResult, archiveResult]) => {
|
||||
setCatalog(nextCatalog);
|
||||
setSources(sourceResult.providers);
|
||||
setArchiveProviders(archiveResult.providers);
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
@@ -227,6 +239,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
{canAdmin && (
|
||||
<Button type="button" variant="ghost" onClick={() => setCatalogOpen(true)} helpContextId="records.catalog.admin">
|
||||
<Settings2 size={16} aria-hidden="true" />
|
||||
Catalog
|
||||
</Button>
|
||||
)}
|
||||
<form className="records-search" onSubmit={submitSearch}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
@@ -335,10 +353,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
<RecordDetailPanel
|
||||
detail={detail}
|
||||
recordClass={classesById.get(detail.record.class_id)}
|
||||
settings={settings}
|
||||
archiveProviders={archiveProviders}
|
||||
canWrite={canWrite}
|
||||
canAdmin={canAdmin}
|
||||
hasSources={sources.length > 0}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onFile={() => setFileOpen(true)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -375,6 +397,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
open={fileOpen}
|
||||
settings={settings}
|
||||
record={detail?.record ?? null}
|
||||
volumes={detail?.volumes ?? []}
|
||||
providers={sources}
|
||||
onClose={() => setFileOpen(false)}
|
||||
onSaved={() => {
|
||||
@@ -382,6 +405,16 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
<RecordCatalogDialog
|
||||
open={catalogOpen}
|
||||
settings={settings}
|
||||
catalog={catalog}
|
||||
onClose={() => setCatalogOpen(false)}
|
||||
onSaved={() => {
|
||||
setCatalogOpen(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -389,17 +422,25 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
|
||||
function RecordDetailPanel({
|
||||
detail,
|
||||
recordClass,
|
||||
settings,
|
||||
archiveProviders,
|
||||
canWrite,
|
||||
canAdmin,
|
||||
hasSources,
|
||||
onEdit,
|
||||
onFile
|
||||
onFile,
|
||||
onChanged
|
||||
}: {
|
||||
detail: RecordDetail;
|
||||
recordClass?: RecordClass;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
archiveProviders: RecordArchiveProvider[];
|
||||
canWrite: boolean;
|
||||
canAdmin: boolean;
|
||||
hasSources: boolean;
|
||||
onEdit: () => void;
|
||||
onFile: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const record = detail.record;
|
||||
return (
|
||||
@@ -414,7 +455,7 @@ function RecordDetailPanel({
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
|
||||
<Button type="button" variant="primary" onClick={onFile} disabledReason={!canWrite ? "Your account may not file record items." : record.state !== "open" ? "Only open records accept new items." : !hasSources ? "No enabled source module provides exact record references." : undefined} helpContextId="records.action.file">
|
||||
<FilePlus2 size={16} aria-hidden="true" />
|
||||
File item
|
||||
</Button>
|
||||
@@ -434,6 +475,15 @@ function RecordDetailPanel({
|
||||
|
||||
{record.description && <p className="records-description">{record.description}</p>}
|
||||
|
||||
<RecordLifecyclePanel
|
||||
detail={detail}
|
||||
archiveProviders={archiveProviders}
|
||||
settings={settings}
|
||||
canWrite={canWrite}
|
||||
canAdmin={canAdmin}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
|
||||
<section className="records-detail-section" data-help-context-id="records.record-items">
|
||||
<div className="records-section-heading">
|
||||
<h2>Contents</h2>
|
||||
@@ -549,7 +599,7 @@ function RecordDialog({
|
||||
file_plan_node_id: selectedClass.file_plan_node_id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
state,
|
||||
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
|
||||
classification: classification.trim() || null,
|
||||
purpose: purpose.trim(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
@@ -601,10 +651,14 @@ function RecordDialog({
|
||||
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
|
||||
</FormField>
|
||||
<FormField label="State" helpContextId="records.field.state">
|
||||
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
|
||||
<option value="planned">Planned</option>
|
||||
<option value="open">Open</option>
|
||||
</select>
|
||||
{mode === "edit" && record && !["planned", "open"].includes(record.state) ? (
|
||||
<input value={humanize(record.state)} disabled />
|
||||
) : (
|
||||
<select value={state} onChange={(event) => setState(event.target.value as "planned" | "open")}>
|
||||
<option value="planned">Planned</option>
|
||||
<option value="open">Open</option>
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Title" helpContextId="records.field.title">
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||
@@ -635,6 +689,7 @@ function FilingDialog({
|
||||
open,
|
||||
settings,
|
||||
record,
|
||||
volumes,
|
||||
providers,
|
||||
onClose,
|
||||
onSaved
|
||||
@@ -642,6 +697,7 @@ function FilingDialog({
|
||||
open: boolean;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
record: RecordEntry | null;
|
||||
volumes: RecordDetail["volumes"];
|
||||
providers: RecordSourceProvider[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
@@ -650,6 +706,7 @@ function FilingDialog({
|
||||
const [resourceType, setResourceType] = useState("");
|
||||
const [resourceId, setResourceId] = useState("");
|
||||
const [sourceRevision, setSourceRevision] = useState("");
|
||||
const [volumeId, setVolumeId] = useState("");
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [filingReason, setFilingReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -663,6 +720,7 @@ function FilingDialog({
|
||||
setResourceType(first?.resource_types[0] ?? "");
|
||||
setResourceId("");
|
||||
setSourceRevision("");
|
||||
setVolumeId("");
|
||||
setPurpose(record?.purpose ?? "");
|
||||
setFilingReason("");
|
||||
setError("");
|
||||
@@ -690,6 +748,7 @@ function FilingDialog({
|
||||
purpose: purpose.trim(),
|
||||
filing_reason: filingReason.trim(),
|
||||
relationship: "contains",
|
||||
volume_id: volumeId || null,
|
||||
idempotency_key: randomId(),
|
||||
institutional_context: record.institutional_context,
|
||||
metadata: {}
|
||||
@@ -741,6 +800,12 @@ function FilingDialog({
|
||||
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
|
||||
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
|
||||
</FormField>
|
||||
<FormField label="Record volume" helpContextId="records.field.volume">
|
||||
<select value={volumeId} onChange={(event) => setVolumeId(event.target.value)}>
|
||||
<option value="">No volume</option>
|
||||
{volumes.map((item) => <option key={item.volume_id} value={item.volume_id}>{item.sequence}. {item.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
|
||||
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
|
||||
</FormField>
|
||||
|
||||
@@ -76,7 +76,80 @@ const en = {
|
||||
"Complete all required record fields.": "Complete all required record fields.",
|
||||
"The item is being filed.": "The item is being filed.",
|
||||
"Complete the exact source and filing reason.": "Complete the exact source and filing reason.",
|
||||
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference."
|
||||
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "The source module verifies your current access and resolves this exact revision before Records stores the reference.",
|
||||
"Catalog": "Catalog",
|
||||
"Administer Records catalog": "Administer Records catalog",
|
||||
"Catalog object": "Catalog object",
|
||||
"File-plan node": "File-plan node",
|
||||
"Revision target": "Revision target",
|
||||
"Node ID": "Node ID",
|
||||
"Class ID": "Class ID",
|
||||
"Code": "Code",
|
||||
"Key": "Key",
|
||||
"Label": "Label",
|
||||
"Parent node": "Parent node",
|
||||
"Top level": "Top level",
|
||||
"Retention period (days)": "Retention period (days)",
|
||||
"Retention trigger": "Retention trigger",
|
||||
"Record closure": "Record closure",
|
||||
"End of closure month": "End of closure month",
|
||||
"End of closure year": "End of closure year",
|
||||
"Explicit date at closure": "Explicit date at closure",
|
||||
"Save revision": "Save revision",
|
||||
"Lifecycle": "Lifecycle",
|
||||
"Add volume": "Add volume",
|
||||
"Close": "Close",
|
||||
"Close record": "Close record",
|
||||
"Reopen": "Reopen",
|
||||
"Appraise": "Appraise",
|
||||
"Apply hold": "Apply hold",
|
||||
"Propose disposition": "Propose disposition",
|
||||
"Finalize approved disposition": "Finalize approved disposition",
|
||||
"Withdraw proposal": "Withdraw proposal",
|
||||
"Prepare transfer": "Prepare transfer",
|
||||
"Run simulation": "Run simulation",
|
||||
"Recovery evidence": "Recovery evidence",
|
||||
"Closed": "Closed",
|
||||
"Retention starts": "Retention starts",
|
||||
"Retention due": "Retention due",
|
||||
"Appraisal": "Appraisal",
|
||||
"Not appraised": "Not appraised",
|
||||
"Volumes": "Volumes",
|
||||
"Holds": "Holds",
|
||||
"Disposition": "Disposition",
|
||||
"Transfer package": "Transfer package",
|
||||
"Simulation only; no archival custody.": "Simulation only; no archival custody.",
|
||||
"Volume label": "Volume label",
|
||||
"Authority": "Authority",
|
||||
"Explicit retention trigger": "Explicit retention trigger",
|
||||
"Archive provider": "Archive provider",
|
||||
"Transfer profile": "Transfer profile",
|
||||
"Reason": "Reason",
|
||||
"Retain": "Retain",
|
||||
"Transfer": "Transfer",
|
||||
"Destroy": "Destroy",
|
||||
"Reclassify": "Reclassify",
|
||||
"Record volume": "Record volume",
|
||||
"No volume": "No volume",
|
||||
"This validates transfer handling only. Archival custody is not transferred.": "This validates transfer handling only. Archival custody is not transferred.",
|
||||
"Recovery evidence is complete.": "Recovery evidence is complete.",
|
||||
"Evidence digest": "Evidence digest",
|
||||
"Source checks": "Source checks",
|
||||
"Recovery operations": "Recovery operations",
|
||||
"Loading recovery evidence...": "Loading recovery evidence...",
|
||||
"Only open records accept new items.": "Only open records accept new items.",
|
||||
"Your account may not create record volumes.": "Your account may not create record volumes.",
|
||||
"Your account may not apply a hold.": "Your account may not apply a hold.",
|
||||
"Your account may not release holds.": "Your account may not release holds.",
|
||||
"Release all active holds first.": "Release all active holds first.",
|
||||
"No healthy archive profile is available.": "No healthy archive profile is available.",
|
||||
"Real archive dispatch needs a target-specific recovery profile.": "Real archive dispatch needs a target-specific recovery profile.",
|
||||
"The lifecycle action is being saved.": "The lifecycle action is being saved.",
|
||||
"Enter the purpose for this action.": "Enter the purpose for this action.",
|
||||
"Enter a reason for this action.": "Enter a reason for this action.",
|
||||
"Enter the hold authority.": "Enter the hold authority.",
|
||||
"Enter a volume label.": "Enter a volume label.",
|
||||
"Select an available archive profile.": "Select an available archive profile."
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
@@ -154,7 +227,80 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Complete all required record fields.": "Füllen Sie alle erforderlichen Aktenfelder aus.",
|
||||
"The item is being filed.": "Das Objekt wird veraktet.",
|
||||
"Complete the exact source and filing reason.": "Vervollständigen Sie die exakte Quelle und die Veraktungsbegründung.",
|
||||
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert."
|
||||
"The source module verifies your current access and resolves this exact revision before Records stores the reference.": "Das Quellmodul prüft Ihre aktuelle Berechtigung und löst diese exakte Revision auf, bevor Records die Referenz speichert.",
|
||||
"Catalog": "Katalog",
|
||||
"Administer Records catalog": "Aktenkatalog verwalten",
|
||||
"Catalog object": "Katalogobjekt",
|
||||
"File-plan node": "Aktenplanposition",
|
||||
"Revision target": "Revisionsziel",
|
||||
"Node ID": "Positions-ID",
|
||||
"Class ID": "Klassen-ID",
|
||||
"Code": "Kennzeichen",
|
||||
"Key": "Schlüssel",
|
||||
"Label": "Bezeichnung",
|
||||
"Parent node": "Übergeordnete Position",
|
||||
"Top level": "Oberste Ebene",
|
||||
"Retention period (days)": "Aufbewahrungsfrist (Tage)",
|
||||
"Retention trigger": "Beginn der Aufbewahrung",
|
||||
"Record closure": "Aktenabschluss",
|
||||
"End of closure month": "Ende des Abschlussmonats",
|
||||
"End of closure year": "Ende des Abschlussjahres",
|
||||
"Explicit date at closure": "Explizites Datum beim Abschluss",
|
||||
"Save revision": "Revision speichern",
|
||||
"Lifecycle": "Lebenszyklus",
|
||||
"Add volume": "Band anlegen",
|
||||
"Close": "Schließen",
|
||||
"Close record": "Akte abschließen",
|
||||
"Reopen": "Wiedereröffnen",
|
||||
"Appraise": "Bewerten",
|
||||
"Apply hold": "Sperre setzen",
|
||||
"Propose disposition": "Aussonderung vorschlagen",
|
||||
"Finalize approved disposition": "Freigegebene Aussonderung abschließen",
|
||||
"Withdraw proposal": "Vorschlag zurückziehen",
|
||||
"Prepare transfer": "Übergabe vorbereiten",
|
||||
"Run simulation": "Simulation ausführen",
|
||||
"Recovery evidence": "Wiederherstellungsnachweis",
|
||||
"Closed": "Abgeschlossen",
|
||||
"Retention starts": "Aufbewahrung beginnt",
|
||||
"Retention due": "Aufbewahrung endet",
|
||||
"Appraisal": "Bewertung",
|
||||
"Not appraised": "Nicht bewertet",
|
||||
"Volumes": "Bände",
|
||||
"Holds": "Sperren",
|
||||
"Disposition": "Aussonderung",
|
||||
"Transfer package": "Übergabepaket",
|
||||
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
|
||||
"Volume label": "Bandbezeichnung",
|
||||
"Authority": "Anordnende Stelle",
|
||||
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
|
||||
"Archive provider": "Archivanbieter",
|
||||
"Transfer profile": "Übergabeprofil",
|
||||
"Reason": "Begründung",
|
||||
"Retain": "Aufbewahren",
|
||||
"Transfer": "Übergeben",
|
||||
"Destroy": "Vernichten",
|
||||
"Reclassify": "Neu klassifizieren",
|
||||
"Record volume": "Aktenband",
|
||||
"No volume": "Kein Band",
|
||||
"This validates transfer handling only. Archival custody is not transferred.": "Dies prüft nur die Übergabeverarbeitung. Eine Archivverwahrung wird nicht übertragen.",
|
||||
"Recovery evidence is complete.": "Der Wiederherstellungsnachweis ist vollständig.",
|
||||
"Evidence digest": "Nachweisprüfsumme",
|
||||
"Source checks": "Quellprüfungen",
|
||||
"Recovery operations": "Wiederherstellungsvorgänge",
|
||||
"Loading recovery evidence...": "Wiederherstellungsnachweis wird geladen...",
|
||||
"Only open records accept new items.": "Nur offene Akten nehmen neue Objekte auf.",
|
||||
"Your account may not create record volumes.": "Ihr Konto darf keine Aktenbände anlegen.",
|
||||
"Your account may not apply a hold.": "Ihr Konto darf keine Sperre setzen.",
|
||||
"Your account may not release holds.": "Ihr Konto darf keine Sperre aufheben.",
|
||||
"Release all active holds first.": "Heben Sie zuerst alle aktiven Sperren auf.",
|
||||
"No healthy archive profile is available.": "Es ist kein verfügbares Archivprofil betriebsbereit.",
|
||||
"Real archive dispatch needs a target-specific recovery profile.": "Eine echte Archivübergabe benötigt ein zielspezifisches Wiederherstellungsprofil.",
|
||||
"The lifecycle action is being saved.": "Die Lebenszyklusaktion wird gespeichert.",
|
||||
"Enter the purpose for this action.": "Geben Sie den Zweck dieser Aktion an.",
|
||||
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
|
||||
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
|
||||
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
|
||||
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil."
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
|
||||
@@ -20,6 +20,7 @@ export const recordsModule: PlatformWebModule = {
|
||||
"reporting",
|
||||
"dms",
|
||||
"policy",
|
||||
"approvals",
|
||||
"audit",
|
||||
"search"
|
||||
],
|
||||
|
||||
@@ -290,6 +290,106 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.records-lifecycle-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding: 10px 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl,
|
||||
.records-recovery-result dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl > div,
|
||||
.records-recovery-result dl > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence dt,
|
||||
.records-recovery-result dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence dd,
|
||||
.records-recovery-result dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.records-evidence-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.records-evidence-list > strong {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.records-evidence-list > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
border-top: var(--border-line);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.records-evidence-list > div > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.records-evidence-list small {
|
||||
color: var(--muted);
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.records-evidence-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.records-recovery-result {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.records-recovery-result dl {
|
||||
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.records-detail-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -457,6 +557,10 @@
|
||||
.records-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.records-lifecycle-evidence > dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -503,6 +607,8 @@
|
||||
}
|
||||
|
||||
.records-facts,
|
||||
.records-lifecycle-evidence > dl,
|
||||
.records-recovery-result dl,
|
||||
.records-access-explanation dl,
|
||||
.records-dialog-form {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user