feat(records): complete eAkte reference journey
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 18:42:09 +02:00
parent 38f203a906
commit a5eee2c23f
15 changed files with 1020 additions and 36 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/records-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+11
View File
@@ -89,9 +89,15 @@ export type RecordItem = {
content_sha256?: string | null;
content_type?: string | null;
size_bytes?: number | null;
source_valid_from?: string | null;
source_valid_to?: string | null;
source_recorded_at?: string | null;
launch_url?: string | null;
filed_at: string;
filed_by?: string | null;
institutional_context: Record<string, unknown>;
source_metadata: Record<string, unknown>;
filing_metadata: Record<string, unknown>;
};
export type RecordChronology = {
@@ -147,10 +153,15 @@ export type RecordTransferPackage = {
provider_id: string;
profile: string;
status: string;
authority_mode: string;
manifest: Record<string, unknown>;
manifest_sha256: string;
receipt: Record<string, unknown>;
receipt_sha256?: string | null;
external_reference?: string | null;
recovery_operation_id?: string | null;
simulated: boolean;
institutional_context: Record<string, unknown>;
recorded_at: string;
};
@@ -314,7 +314,7 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
<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.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}{hold.policy_refs.length > 0 ? ` · policy ${hold.policy_refs.join(", ")}` : ""}</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>
@@ -322,11 +322,20 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
}
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>;
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.policy_refs.length > 0 && <small>Policy: {item.policy_refs.join(", ")}</small>}{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>;
const receiptMetadata = typeof item.receipt.metadata === "object" && item.receipt.metadata != null
? item.receipt.metadata as Record<string, unknown>
: {};
const custodyTransferred = receiptMetadata.custody_transferred === true;
const custodySummary = item.simulated
? "Simulation only; no archival custody."
: custodyTransferred
? "Archive custody was transferred."
: "Archive custody was not transferred.";
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><small>{custodySummary} Authority: {humanize(item.authority_mode)}.</small></div>;
}
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
@@ -464,6 +464,7 @@ function RecordDetailPanel({
onChanged: () => void;
}) {
const record = detail.record;
const governanceContext = recordContextFacts(record.institutional_context);
return (
<PageScrollViewport className="records-detail-scroll">
<div className="records-detail-header">
@@ -496,6 +497,17 @@ function RecordDetailPanel({
{record.description && <p className="records-description">{record.description}</p>}
{governanceContext.length > 0 && (
<section className="records-detail-section" data-help-context-id="records.governance-context">
<div className="records-section-heading"><h2>Governance context</h2></div>
<dl className="records-context-facts">
{governanceContext.map(([label, value]) => (
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
</section>
)}
<RecordLifecyclePanel
detail={detail}
archiveProviders={archiveProviders}
@@ -525,6 +537,15 @@ function RecordDetailPanel({
<time>{formatDateTime(item.filed_at)}</time>
</div>
<p>{item.filing_reason}</p>
<dl className="records-item-evidence">
<div><dt>Authority</dt><dd>{humanize(item.authority_mode)}</dd></div>
<div><dt>Relationship</dt><dd>{humanize(item.relationship)}</dd></div>
<div><dt>Purpose</dt><dd>{item.purpose}</dd></div>
<div><dt>Source recorded</dt><dd>{formatDateTime(item.source_recorded_at)}</dd></div>
{typeof item.source_metadata.evidence_role === "string" && (
<div><dt>Evidence role</dt><dd>{humanize(item.source_metadata.evidence_role)}</dd></div>
)}
</dl>
</div>
))}
</div>
@@ -882,6 +903,27 @@ function formatBytes(value?: number | null): string {
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function recordContextFacts(context: Record<string, unknown>): Array<[string, string]> {
const fields: Array<[string, string]> = [
["Service", "service_id"],
["Case", "case_id"],
["Applicant", "applicant_party_id"],
["Representative", "representative_party_id"],
["Representation", "representation_id"],
["Authority", "authority_id"],
["Responsible function", "responsible_function_id"],
["Mandate", "mandate_id"],
["Legal basis", "legal_basis"],
["Retention policy", "retention_policy_ref"]
];
return fields.flatMap(([label, key]) => {
const value = context[key];
if (typeof value === "string" && value.trim()) return [[label, value]];
if (Array.isArray(value) && value.length > 0) return [[label, value.map(String).join(", ")]];
return [];
});
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+30
View File
@@ -41,6 +41,16 @@ const en = {
"Retention input": "Retention input",
"Not classified": "Not classified",
"Not configured": "Not configured",
"Governance context": "Governance context",
"Service": "Service",
"Case": "Case",
"Applicant": "Applicant",
"Representative": "Representative",
"Representation": "Representation",
"Responsible function": "Responsible function",
"Mandate": "Mandate",
"Legal basis": "Legal basis",
"Retention policy": "Retention policy",
"Contents": "Contents",
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
"Chronology": "Chronology",
@@ -66,6 +76,9 @@ const en = {
"Source object ID": "Source object ID",
"Exact source revision": "Exact source revision",
"Filing reason": "Filing reason",
"Relationship": "Relationship",
"Source recorded": "Source recorded",
"Evidence role": "Evidence role",
"Not set": "Not set",
"Size unavailable": "Size unavailable",
"Your account may view records but may not create them.": "Your account may view records but may not create them.",
@@ -121,6 +134,8 @@ const en = {
"Disposition": "Disposition",
"Transfer package": "Transfer package",
"Simulation only; no archival custody.": "Simulation only; no archival custody.",
"Archive custody was transferred.": "Archive custody was transferred.",
"Archive custody was not transferred.": "Archive custody was not transferred.",
"Volume label": "Volume label",
"Authority": "Authority",
"Explicit retention trigger": "Explicit retention trigger",
@@ -194,6 +209,16 @@ const de: Record<keyof typeof en, string> = {
"Retention input": "Aufbewahrungsvorgabe",
"Not classified": "Nicht klassifiziert",
"Not configured": "Nicht konfiguriert",
"Governance context": "Governance-Kontext",
"Service": "Leistung",
"Case": "Vorgang",
"Applicant": "Antragstellende Person",
"Representative": "Vertretende Person",
"Representation": "Vertretung",
"Responsible function": "Zuständige Funktion",
"Mandate": "Mandat",
"Legal basis": "Rechtsgrundlage",
"Retention policy": "Aufbewahrungsrichtlinie",
"Contents": "Inhalt",
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
"Chronology": "Chronologie",
@@ -219,6 +244,9 @@ const de: Record<keyof typeof en, string> = {
"Source object ID": "ID des Quellobjekts",
"Exact source revision": "Exakte Quellrevision",
"Filing reason": "Veraktungsbegründung",
"Relationship": "Beziehung",
"Source recorded": "Quelle erfasst am",
"Evidence role": "Nachweisrolle",
"Not set": "Nicht gesetzt",
"Size unavailable": "Größe nicht verfügbar",
"Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.",
@@ -274,6 +302,8 @@ const de: Record<keyof typeof en, string> = {
"Disposition": "Aussonderung",
"Transfer package": "Übergabepaket",
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
"Archive custody was transferred.": "Die Archivverwahrung wurde übertragen.",
"Archive custody was not transferred.": "Die Archivverwahrung wurde nicht übertragen.",
"Volume label": "Bandbezeichnung",
"Authority": "Anordnende Stelle",
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
+49
View File
@@ -253,6 +253,42 @@
line-height: 1.55;
}
.records-context-facts,
.records-item-evidence {
display: grid;
margin: 0;
}
.records-context-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-context-facts > div {
min-width: 0;
padding: 9px 10px;
background: var(--surface);
}
.records-context-facts dt,
.records-item-evidence dt {
color: var(--muted);
font-size: 12px;
}
.records-context-facts dd,
.records-item-evidence dd {
overflow-wrap: anywhere;
margin: 3px 0 0;
color: var(--text-strong);
font-size: var(--font-size-sm);
font-weight: 600;
}
.records-lifecycle-actions {
display: flex;
flex-wrap: wrap;
@@ -424,6 +460,17 @@
font-size: var(--font-size-sm);
}
.records-item-evidence {
grid-column: 1 / -1;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding-top: 4px;
}
.records-item-evidence > div {
min-width: 0;
}
.records-chronology > div {
position: relative;
display: grid;
@@ -564,6 +611,8 @@
}
.records-facts,
.records-context-facts,
.records-item-evidence,
.records-lifecycle-evidence > dl,
.records-recovery-result dl,
.records-access-explanation dl,