feat(records): implement native eAkte vertical

This commit is contained in:
2026-08-06 01:43:23 +02:00
parent 59860f7ac0
commit f316523a1d
28 changed files with 5700 additions and 80 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/records-webui",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/records.css": "./src/styles/records.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type FilePlanNode = {
node_id: string;
revision: number;
parent_node_id?: string | null;
code: string;
label: string;
description?: string | null;
active: boolean;
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
institutional_context: Record<string, unknown>;
};
export type RecordClass = {
class_id: string;
revision: number;
file_plan_node_id: string;
key: string;
label: string;
description?: string | null;
metadata_requirements: string[];
allowed_source_types: string[];
retention_period_days?: number | null;
closure_trigger?: string | null;
access_mode: "tenant" | "restricted";
active: boolean;
};
export type RecordCatalog = {
file_plan: FilePlanNode[];
classes: RecordClass[];
};
export type RecordEntry = {
record_id: string;
record_number: string;
revision: number;
class_id: string;
file_plan_node_id: string;
title: string;
description?: string | null;
state: "planned" | "open" | string;
source_authority_mode: string;
access_mode: "tenant" | "restricted";
purpose: string;
classification?: string | null;
responsible_unit_id?: string | null;
responsible_function_id?: string | null;
external_reference: Record<string, unknown>;
institutional_context: Record<string, unknown>;
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
};
export type RecordVolume = {
volume_id: string;
sequence: number;
label: string;
state: string;
recorded_at: string;
};
export type RecordItem = {
item_id: string;
sequence: number;
volume_id?: string | null;
source: {
source_module: string;
resource_type: string;
resource_id: string;
source_revision: string;
};
label: string;
relationship: string;
filing_reason: string;
purpose: string;
authority_mode: string;
content_sha256?: string | null;
content_type?: string | null;
size_bytes?: number | null;
launch_url?: string | null;
filed_at: string;
filed_by?: string | null;
};
export type RecordChronology = {
event_id: string;
event_type: string;
record_revision: number;
summary: string;
occurred_at: string;
actor_id?: string | null;
purpose: string;
payload: Record<string, unknown>;
};
export type RecordDetail = {
record: RecordEntry;
volumes: RecordVolume[];
items: RecordItem[];
chronology: RecordChronology[];
access_explanation: {
decision: string;
reason: string;
purpose: string;
current_authorization: boolean;
access_mode: string;
limitations: string[];
};
};
export type RecordSourceProvider = {
id: string;
source_module: string;
resource_types: string[];
};
export function listRecords(
settings: ApiSettings,
options: {
query?: string;
state?: string;
classId?: string;
filePlanNodeId?: string;
offset?: number;
limit?: number;
},
signal?: AbortSignal
): Promise<{ records: RecordEntry[]; total: number; offset: number; limit: number }> {
return apiFetch(settings, apiPath("/api/v1/records", {
query: options.query,
state: options.state,
class_id: options.classId,
file_plan_node_id: options.filePlanNodeId,
offset: options.offset,
limit: options.limit ?? 50
}), { signal });
}
export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise<RecordDetail> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal });
}
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
return apiFetch(settings, "/api/v1/records/catalog", { signal });
}
export function getRecordSources(settings: ApiSettings, signal?: AbortSignal): Promise<{ providers: RecordSourceProvider[] }> {
return apiFetch(settings, "/api/v1/records/sources", { signal });
}
export function createRecord(settings: ApiSettings, payload: Record<string, unknown>): Promise<RecordEntry> {
return apiFetch(settings, "/api/v1/records", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateRecord(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordEntry> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function fileRecordItem(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/items`, {
method: "POST",
body: JSON.stringify(payload)
});
}
+802
View File
@@ -0,0 +1,802 @@
import {
Archive,
FilePlus2,
Pencil,
Plus,
RefreshCw,
Search
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
i18nMessage,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useTemporalDataContext,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createRecord,
fileRecordItem,
getRecord,
getRecordCatalog,
getRecordSources,
listRecords,
updateRecord,
type FilePlanNode,
type RecordCatalog,
type RecordClass,
type RecordDetail,
type RecordEntry,
type RecordSourceProvider
} from "../../api/records";
import {
RECORDS_DOCUMENTATION,
RECORDS_FIELD_DOCUMENTATION
} from "./interfacePatterns";
const EMPTY_CATALOG: RecordCatalog = { file_plan: [], classes: [] };
const PAGE_SIZE = 50;
export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [searchParams, setSearchParams] = useSearchParams();
const { selection, selectionKey, isDefault: temporalIsDefault } = useTemporalDataContext();
const [catalog, setCatalog] = useState<RecordCatalog>(EMPTY_CATALOG);
const [records, setRecords] = useState<RecordEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedRecordId, setSelectedRecordId] = useState(
() => searchParams.get("recordId") ?? ""
);
const [detail, setDetail] = useState<RecordDetail | null>(null);
const [sources, setSources] = useState<RecordSourceProvider[]>([]);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState("");
const [detailError, setDetailError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const canWrite = auth.scopes.includes("records:workspace:write") ||
auth.scopes.includes("records:workspace:admin");
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
setError("");
Promise.all([
getRecordCatalog(settings, controller.signal),
canWrite
? getRecordSources(settings, controller.signal)
: Promise.resolve({ providers: [] as RecordSourceProvider[] })
]).
then(([nextCatalog, sourceResult]) => {
setCatalog(nextCatalog);
setSources(sourceResult.providers);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(errorMessage(reason, "The Records catalog could not be loaded."));
}
});
return () => controller.abort();
}, [canWrite, reloadKey, selectionKey, settings]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
listRecords(settings, {
query: submittedQuery,
filePlanNodeId: selectedNodeId || undefined,
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE
}, controller.signal).
then((result) => {
setRecords(result.records);
setTotal(result.total);
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
if (page > lastPage) setPage(lastPage);
setSelectedRecordId((current) => {
if (current && result.records.some((item) => item.record_id === current)) return current;
return result.records[0]?.record_id ?? "";
});
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(errorMessage(reason, "Records could not be loaded."));
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]);
useEffect(() => {
if (!selectedRecordId) {
setDetail(null);
setDetailError("");
return undefined;
}
const controller = new AbortController();
setDetailLoading(true);
setDetailError("");
getRecord(settings, selectedRecordId, controller.signal).
then(setDetail).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setDetail(null);
setDetailError(errorMessage(reason, "The record could not be loaded."));
}
}).
finally(() => setDetailLoading(false));
return () => controller.abort();
}, [reloadKey, selectedRecordId, selectionKey, settings]);
useEffect(() => {
const current = searchParams.get("recordId") ?? "";
if (current === selectedRecordId) return;
const next = new URLSearchParams(searchParams);
if (selectedRecordId) next.set("recordId", selectedRecordId);
else next.delete("recordId");
setSearchParams(next, { replace: true });
}, [searchParams, selectedRecordId, setSearchParams]);
const classesById = useMemo(
() => new Map(catalog.classes.map((item) => [item.class_id, item])),
[catalog.classes]
);
const filePlanRows = useMemo(() => orderedFilePlan(catalog.file_plan), [catalog.file_plan]);
const columns = useMemo<DataGridColumn<RecordEntry>[]>(() => [
{
id: "number",
header: "Record number",
width: 145,
minWidth: 120,
resizable: true,
value: (row) => row.record_number,
render: (row) => <span className="records-number">{row.record_number}</span>
},
{
id: "title",
header: "Title",
width: "1fr",
minWidth: 180,
resizable: true,
value: (row) => row.title,
render: (row) => (
<button
type="button"
className={`records-record-link${selectedRecordId === row.record_id ? " selected" : ""}`}
onClick={() => setSelectedRecordId(row.record_id)}
>
{row.title}
</button>
)
},
{
id: "state",
header: "State",
width: 110,
minWidth: 95,
value: (row) => row.state,
render: (row) => <StatusBadge status={row.state === "open" ? "active" : "warning"} label={humanize(row.state)} />
}
], [selectedRecordId]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setPage(1);
setSubmittedQuery(query.trim());
}
return (
<main className="records-page" data-help-context-id="records.workspace">
<div className="records-shell">
<div className="records-toolbar">
<Button
type="button"
variant="primary"
onClick={() => setCreateOpen(true)}
disabledReason={!canWrite ? "Your account may view records but may not create them." : catalog.classes.length === 0 ? "Configure a record class before creating a record." : undefined}
helpContextId="records.action.create"
>
<Plus size={16} aria-hidden="true" />
New record
</Button>
<Button type="button" variant="ghost" onClick={reload} disabledReason={loading ? "Records are already loading." : undefined}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
<form className="records-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search records"
placeholder="Search records"
/>
</form>
<span className="records-result-count">
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
</span>
{!temporalIsDefault && (
<StatusBadge
status="warning"
label={selection.validityMode === "all" ? "All valid-time data" : "Historical data"}
/>
)}
<DocumentationHelpLink reference={RECORDS_DOCUMENTATION} />
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="records-workspace">
<aside className="records-file-plan" data-help-context-id="records.file-plan">
<div className="records-pane-heading">
<div>
<span>File plan</span>
<strong>{catalog.file_plan.length}</strong>
</div>
</div>
<PageScrollViewport className="records-file-plan-scroll">
<button
type="button"
className={`records-plan-row${selectedNodeId === "" ? " selected" : ""}`}
onClick={() => {
setPage(1);
setSelectedNodeId("");
}}
>
<Archive size={16} aria-hidden="true" />
<span>All records</span>
</button>
{filePlanRows.map(({ node, depth }) => (
<button
type="button"
key={node.node_id}
className={`records-plan-row${selectedNodeId === node.node_id ? " selected" : ""}`}
style={{ paddingInlineStart: `${12 + depth * 18}px` }}
onClick={() => {
setPage(1);
setSelectedNodeId(node.node_id);
}}
>
<span className="records-plan-code">{node.code}</span>
<span>{node.label}</span>
</button>
))}
{!loading && catalog.file_plan.length === 0 && (
<p className="records-empty-note">No file-plan nodes are configured.</p>
)}
</PageScrollViewport>
</aside>
<section className="records-list-pane" data-help-context-id="records.record-list">
<div className="records-pane-heading">
<div>
<span>Records</span>
<strong>{selectedNodeId ? catalog.file_plan.find((item) => item.node_id === selectedNodeId)?.label : "All"}</strong>
</div>
</div>
<div className="records-list-grid">
{loading ? (
<LoadingIndicator label="Loading records" />
) : (
<DataGrid
id="records-workspace-list"
rows={records}
columns={columns}
getRowKey={(row) => row.record_id}
emptyText="No matching records."
initialFit="container"
resizeBehavior="cover"
pagination={{
mode: "server",
page,
pageSize: PAGE_SIZE,
totalRows: total,
disabled: loading,
onPageChange: setPage
}}
/>
)}
</div>
</section>
<section className="records-detail-pane" data-help-context-id="records.record-detail">
{detailLoading && <LoadingIndicator label="Loading record" />}
{detailError && <DismissibleAlert tone="danger" resetKey={detailError}>{detailError}</DismissibleAlert>}
{!detailLoading && !detailError && !detail && (
<div className="records-detail-empty">
<Archive size={28} aria-hidden="true" />
<p>Select a record to inspect its contents and chronology.</p>
</div>
)}
{!detailLoading && detail && (
<RecordDetailPanel
detail={detail}
recordClass={classesById.get(detail.record.class_id)}
canWrite={canWrite}
hasSources={sources.length > 0}
onEdit={() => setEditOpen(true)}
onFile={() => setFileOpen(true)}
/>
)}
</section>
</div>
</div>
<RecordDialog
open={createOpen}
mode="create"
settings={settings}
catalog={catalog}
onClose={() => setCreateOpen(false)}
onSaved={(saved) => {
setCreateOpen(false);
setSelectedNodeId("");
setSelectedRecordId(saved.record_id);
reload();
}}
/>
<RecordDialog
open={editOpen}
mode="edit"
settings={settings}
catalog={catalog}
record={detail?.record ?? null}
onClose={() => setEditOpen(false)}
onSaved={(saved) => {
setEditOpen(false);
setSelectedRecordId(saved.record_id);
reload();
}}
/>
<FilingDialog
open={fileOpen}
settings={settings}
record={detail?.record ?? null}
providers={sources}
onClose={() => setFileOpen(false)}
onSaved={() => {
setFileOpen(false);
reload();
}}
/>
</main>
);
}
function RecordDetailPanel({
detail,
recordClass,
canWrite,
hasSources,
onEdit,
onFile
}: {
detail: RecordDetail;
recordClass?: RecordClass;
canWrite: boolean;
hasSources: boolean;
onEdit: () => void;
onFile: () => void;
}) {
const record = detail.record;
return (
<PageScrollViewport className="records-detail-scroll">
<div className="records-detail-header">
<div>
<span className="records-eyebrow">{record.record_number}</span>
<h1>{record.title}</h1>
</div>
<div className="records-detail-actions">
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
<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">
<FilePlus2 size={16} aria-hidden="true" />
File item
</Button>
</div>
</div>
<div className="records-facts">
<div><span>State</span><StatusBadge status={record.state === "open" ? "active" : "warning"} label={humanize(record.state)} /></div>
<div><span>Record class</span><strong>{recordClass?.label ?? record.class_id}</strong></div>
<div><span>Revision</span><strong>{record.revision}</strong></div>
<div><span>Source authority</span><strong>{humanize(record.source_authority_mode)}</strong></div>
<div><span>Valid from</span><strong>{formatDateTime(record.valid_from)}</strong></div>
<div><span>Recorded at</span><strong>{formatDateTime(record.recorded_at)}</strong></div>
<div><span>Classification</span><strong>{record.classification || "Not classified"}</strong></div>
<div><span>Retention input</span><strong>{recordClass?.retention_period_days == null ? "Not configured" : `${recordClass.retention_period_days} days`}</strong></div>
</div>
{record.description && <p className="records-description">{record.description}</p>}
<section className="records-detail-section" data-help-context-id="records.record-items">
<div className="records-section-heading">
<h2>Contents</h2>
<span>{detail.items.length}</span>
</div>
{detail.items.length === 0 ? (
<p className="records-empty-note">No items have been filed in this temporal view.</p>
) : (
<div className="records-item-list">
{detail.items.map((item) => (
<div className="records-item-row" key={item.item_id}>
<div>
{item.launch_url ? <a href={item.launch_url}>{item.label}</a> : <strong>{item.label}</strong>}
<span>{item.source.source_module} · {humanize(item.source.resource_type)} · revision {item.source.source_revision}</span>
</div>
<div>
<span>{formatBytes(item.size_bytes)}</span>
<time>{formatDateTime(item.filed_at)}</time>
</div>
<p>{item.filing_reason}</p>
</div>
))}
</div>
)}
</section>
<section className="records-detail-section" data-help-context-id="records.chronology">
<div className="records-section-heading">
<h2>Chronology</h2>
<span>{detail.chronology.length}</span>
</div>
<div className="records-chronology">
{detail.chronology.map((entry) => (
<div key={entry.event_id}>
<span className="records-timeline-marker" aria-hidden="true" />
<div>
<strong>{entry.summary}</strong>
<span>{humanize(entry.event_type)} · {entry.purpose}</span>
</div>
<time>{formatDateTime(entry.occurred_at)}</time>
</div>
))}
</div>
</section>
<section className="records-detail-section records-access-explanation">
<div className="records-section-heading"><h2>Access and purpose</h2></div>
<p>{detail.access_explanation.reason}</p>
<dl>
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
</dl>
{detail.access_explanation.limitations.map((limitation) => (
<DismissibleAlert key={limitation} tone="warning" dismissible={false} compact>{limitation}</DismissibleAlert>
))}
</section>
</PageScrollViewport>
);
}
function RecordDialog({
open,
mode,
settings,
catalog,
record,
onClose,
onSaved
}: {
open: boolean;
mode: "create" | "edit";
settings: PlatformRouteContext["settings"];
catalog: RecordCatalog;
record?: RecordEntry | null;
onClose: () => void;
onSaved: (record: RecordEntry) => void;
}) {
const firstClass = catalog.classes.find((item) => item.active);
const [recordNumber, setRecordNumber] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [classId, setClassId] = useState(firstClass?.class_id ?? "");
const [state, setState] = useState<"planned" | "open">("open");
const [classification, setClassification] = useState("");
const [purpose, setPurpose] = useState("");
const [changeReason, setChangeReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setRecordNumber(record?.record_number ?? "");
setTitle(record?.title ?? "");
setDescription(record?.description ?? "");
setClassId(record?.class_id ?? firstClass?.class_id ?? "");
setState(record?.state === "planned" ? "planned" : "open");
setClassification(record?.classification ?? "");
setPurpose(record?.purpose ?? "");
setChangeReason("");
setError("");
}, [firstClass?.class_id, open, record]);
const selectedClass = catalog.classes.find((item) => item.class_id === classId);
async function submit(event: FormEvent) {
event.preventDefault();
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return;
setSaving(true);
setError("");
try {
const common = {
class_id: selectedClass.class_id,
file_plan_node_id: selectedClass.file_plan_node_id,
title: title.trim(),
description: description.trim() || null,
state,
classification: classification.trim() || null,
purpose: purpose.trim(),
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: randomId()
};
const saved = mode === "create"
? await createRecord(settings, {
...common,
record_number: recordNumber.trim(),
source_authority_mode: "native_authoritative",
access_mode: "tenant",
valid_from: new Date().toISOString(),
institutional_context: {}
})
: await updateRecord(settings, record!.record_id, {
...common,
expected_revision: record!.revision
});
onSaved(saved);
} catch (reason) {
setError(errorMessage(reason, "The record could not be saved."));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
title={mode === "create" ? "Create record" : "Edit record"}
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId={mode === "create" ? "records.action.create" : "records.action.edit"}
footer={
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
{saving ? "Saving" : "Save record"}
</Button>
</>
}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="records-record-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Record number" helpContextId="records.field.record-number">
<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>
</FormField>
<FormField label="Title" helpContextId="records.field.title">
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
</FormField>
<FormField label="Record class" helpContextId="records.field.class">
<select value={classId} onChange={(event) => setClassId(event.target.value)} required>
{catalog.classes.filter((item) => item.active).map((item) => <option key={item.class_id} value={item.class_id}>{item.label}</option>)}
</select>
</FormField>
<FormField label="Classification" helpContextId="records.field.classification">
<input value={classification} onChange={(event) => setClassification(event.target.value)} />
</FormField>
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
<FormField label="Description" helpContextId="records.field.description">
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
</FormField>
<FormField label="Change reason" helpContextId="records.field.change-reason">
<textarea value={changeReason} onChange={(event) => setChangeReason(event.target.value)} rows={3} required />
</FormField>
</form>
</Dialog>
);
}
function FilingDialog({
open,
settings,
record,
providers,
onClose,
onSaved
}: {
open: boolean;
settings: PlatformRouteContext["settings"];
record: RecordEntry | null;
providers: RecordSourceProvider[];
onClose: () => void;
onSaved: () => void;
}) {
const [sourceModule, setSourceModule] = useState("");
const [resourceType, setResourceType] = useState("");
const [resourceId, setResourceId] = useState("");
const [sourceRevision, setSourceRevision] = useState("");
const [purpose, setPurpose] = useState("");
const [filingReason, setFilingReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const provider = providers.find((item) => item.source_module === sourceModule);
useEffect(() => {
if (!open) return;
const first = providers[0];
setSourceModule(first?.source_module ?? "");
setResourceType(first?.resource_types[0] ?? "");
setResourceId("");
setSourceRevision("");
setPurpose(record?.purpose ?? "");
setFilingReason("");
setError("");
}, [open, providers, record?.purpose]);
useEffect(() => {
if (provider?.resource_types.includes(resourceType)) return;
setResourceType(provider?.resource_types[0] ?? "");
}, [provider, resourceType]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!record || !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim()) return;
setSaving(true);
setError("");
try {
await fileRecordItem(settings, record.record_id, {
source: {
source_module: sourceModule,
resource_type: resourceType,
resource_id: resourceId.trim(),
source_revision: sourceRevision.trim(),
metadata: {}
},
purpose: purpose.trim(),
filing_reason: filingReason.trim(),
relationship: "contains",
idempotency_key: randomId(),
institutional_context: record.institutional_context,
metadata: {}
});
onSaved();
} catch (reason) {
setError(errorMessage(reason, "The source revision could not be filed."));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
title="File exact source revision"
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog"
helpContextId="records.action.file"
footer={
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-filing-form" variant="primary" disabledReason={saving ? "The item is being filed." : !sourceModule || !resourceType || !resourceId.trim() || !sourceRevision.trim() || !purpose.trim() || !filingReason.trim() ? "Complete the exact source and filing reason." : undefined}>
{saving ? "Filing" : "File item"}
</Button>
</>
}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<DismissibleAlert tone="info" dismissible={false} compact>
The source module verifies your current access and resolves this exact revision before Records stores the reference.
</DismissibleAlert>
<form id="records-filing-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Source module" helpContextId="records.field.source-module">
<select value={sourceModule} onChange={(event) => setSourceModule(event.target.value)} required>
{providers.map((item) => <option key={item.source_module} value={item.source_module}>{humanize(item.source_module)}</option>)}
</select>
</FormField>
<FormField label="Source type" helpContextId="records.field.source-object">
<select value={resourceType} onChange={(event) => setResourceType(event.target.value)} required>
{(provider?.resource_types ?? []).map((item) => <option key={item} value={item}>{humanize(item)}</option>)}
</select>
</FormField>
<FormField label="Source object ID" helpContextId="records.field.source-object">
<input value={resourceId} onChange={(event) => setResourceId(event.target.value)} required />
</FormField>
<FormField label="Exact source revision" documentation={RECORDS_FIELD_DOCUMENTATION.sourceRevision}>
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} required />
</FormField>
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
<FormField label="Filing reason" documentation={RECORDS_FIELD_DOCUMENTATION.filingReason}>
<textarea value={filingReason} onChange={(event) => setFilingReason(event.target.value)} rows={4} required />
</FormField>
</form>
</Dialog>
);
}
function orderedFilePlan(nodes: FilePlanNode[]): Array<{ node: FilePlanNode; depth: number }> {
const children = new Map<string, FilePlanNode[]>();
for (const node of nodes) {
const parent = node.parent_node_id ?? "";
children.set(parent, [...(children.get(parent) ?? []), node]);
}
for (const values of children.values()) values.sort((left, right) => left.code.localeCompare(right.code));
const result: Array<{ node: FilePlanNode; depth: number }> = [];
const visited = new Set<string>();
function visit(parent: string, depth: number) {
for (const node of children.get(parent) ?? []) {
if (visited.has(node.node_id)) continue;
visited.add(node.node_id);
result.push({ node, depth });
visit(node.node_id, depth + 1);
}
}
visit("", 0);
for (const node of nodes) {
if (!visited.has(node.node_id)) result.push({ node, depth: 0 });
}
return result;
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Not set";
}
function formatBytes(value?: number | null): string {
if (value == null) return "Size unavailable";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback;
}
@@ -0,0 +1,23 @@
export const RECORDS_DOCUMENTATION = {
topicId: "records.workspace",
contextId: "records.workspace",
documentationType: "user" as const
};
export const RECORDS_FIELD_DOCUMENTATION = {
purpose: {
topicId: "records.filing",
contextId: "records.field.purpose",
documentationType: "user" as const
},
filingReason: {
topicId: "records.filing",
contextId: "records.field.filing-reason",
documentationType: "user" as const
},
sourceRevision: {
topicId: "records.filing",
contextId: "records.field.source-revision",
documentationType: "user" as const
}
};
+160
View File
@@ -0,0 +1,160 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-records.records": "Records",
"i18n:govoplan-records.navigation": "Records navigation",
"i18n:govoplan-records.workspace": "eAkte workspace",
"i18n:govoplan-records.file_plan": "File plan",
"i18n:govoplan-records.record_list": "Record list",
"i18n:govoplan-records.record_detail": "Record detail",
"i18n:govoplan-records.file_source": "File source revision",
"i18n:govoplan-records.record_count": "{value0} records",
"Records": "Records",
"New record": "New record",
"Refresh": "Refresh",
"Search records": "Search records",
"All valid-time data": "All valid-time data",
"Historical data": "Historical data",
"File plan": "File plan",
"All records": "All records",
"All": "All",
"Record number": "Record number",
"Title": "Title",
"State": "State",
"No matching records.": "No matching records.",
"The Records catalog could not be loaded.": "The Records catalog could not be loaded.",
"No file-plan nodes are configured.": "No file-plan nodes are configured.",
"Loading records": "Loading records",
"Loading record": "Loading record",
"Select a record to inspect its contents and chronology.": "Select a record to inspect its contents and chronology.",
"Edit": "Edit",
"File item": "File item",
"Record class": "Record class",
"Revision": "Revision",
"Source authority": "Source authority",
"Valid from": "Valid from",
"Recorded at": "Recorded at",
"Classification": "Classification",
"Retention input": "Retention input",
"Not classified": "Not classified",
"Not configured": "Not configured",
"Contents": "Contents",
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
"Chronology": "Chronology",
"Access and purpose": "Access and purpose",
"Record purpose": "Record purpose",
"Authorization": "Authorization",
"Current authorization applied": "Current authorization applied",
"Not evaluated": "Not evaluated",
"Create record": "Create record",
"Edit record": "Edit record",
"Cancel": "Cancel",
"Saving": "Saving",
"Save record": "Save record",
"Planned": "Planned",
"Open": "Open",
"Purpose": "Purpose",
"Description": "Description",
"Change reason": "Change reason",
"File exact source revision": "File exact source revision",
"Filing": "Filing",
"Source module": "Source module",
"Source type": "Source type",
"Source object ID": "Source object ID",
"Exact source revision": "Exact source revision",
"Filing reason": "Filing reason",
"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.",
"Configure a record class before creating a record.": "Configure a record class before creating a record.",
"Records are already loading.": "Records are already loading.",
"Your account may not revise records.": "Your account may not revise records.",
"Your account may not file record items.": "Your account may not file record items.",
"No enabled source module provides exact record references.": "No enabled source module provides exact record references.",
"The record is being saved.": "The record is being saved.",
"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."
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-records.records": "Akten",
"i18n:govoplan-records.navigation": "Aktennavigation",
"i18n:govoplan-records.workspace": "eAkte-Arbeitsbereich",
"i18n:govoplan-records.file_plan": "Aktenplan",
"i18n:govoplan-records.record_list": "Aktenliste",
"i18n:govoplan-records.record_detail": "Aktendetails",
"i18n:govoplan-records.file_source": "Quellrevision verakten",
"i18n:govoplan-records.record_count": "{value0} Akten",
"Records": "Akten",
"New record": "Neue Akte",
"Refresh": "Aktualisieren",
"Search records": "Akten durchsuchen",
"All valid-time data": "Alle Gültigkeitszeiträume",
"Historical data": "Historische Daten",
"File plan": "Aktenplan",
"All records": "Alle Akten",
"All": "Alle",
"Record number": "Aktenzeichen",
"Title": "Titel",
"State": "Status",
"No matching records.": "Keine passenden Akten.",
"The Records catalog could not be loaded.": "Der Aktenkatalog konnte nicht geladen werden.",
"No file-plan nodes are configured.": "Es sind keine Aktenplanpositionen konfiguriert.",
"Loading records": "Akten werden geladen",
"Loading record": "Akte wird geladen",
"Select a record to inspect its contents and chronology.": "Wählen Sie eine Akte aus, um Inhalt und Chronologie einzusehen.",
"Edit": "Bearbeiten",
"File item": "Objekt verakten",
"Record class": "Aktenklasse",
"Revision": "Revision",
"Source authority": "Quellautorität",
"Valid from": "Gültig ab",
"Recorded at": "Erfasst am",
"Classification": "Klassifikation",
"Retention input": "Aufbewahrungsvorgabe",
"Not classified": "Nicht klassifiziert",
"Not configured": "Nicht konfiguriert",
"Contents": "Inhalt",
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
"Chronology": "Chronologie",
"Access and purpose": "Zugriff und Zweck",
"Record purpose": "Aktenzweck",
"Authorization": "Berechtigung",
"Current authorization applied": "Aktuelle Berechtigung angewendet",
"Not evaluated": "Nicht geprüft",
"Create record": "Akte anlegen",
"Edit record": "Akte bearbeiten",
"Cancel": "Abbrechen",
"Saving": "Speichert",
"Save record": "Akte speichern",
"Planned": "Geplant",
"Open": "Offen",
"Purpose": "Zweck",
"Description": "Beschreibung",
"Change reason": "Änderungsbegründung",
"File exact source revision": "Exakte Quellrevision verakten",
"Filing": "Veraktet",
"Source module": "Quellmodul",
"Source type": "Quelltyp",
"Source object ID": "ID des Quellobjekts",
"Exact source revision": "Exakte Quellrevision",
"Filing reason": "Veraktungsbegründung",
"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.",
"Configure a record class before creating a record.": "Konfigurieren Sie eine Aktenklasse, bevor Sie eine Akte anlegen.",
"Records are already loading.": "Akten werden bereits geladen.",
"Your account may not revise records.": "Ihr Konto darf Akten nicht ändern.",
"Your account may not file record items.": "Ihr Konto darf keine Objekte verakten.",
"No enabled source module provides exact record references.": "Kein aktiviertes Quellmodul stellt exakte Aktenreferenzen bereit.",
"The record is being saved.": "Die Akte wird gespeichert.",
"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."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, recordsModule } from "./module";
export * from "./api/records";
+54
View File
@@ -0,0 +1,54 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/records.css";
const RecordsPage = lazy(() => import("./features/records/RecordsPage"));
export const recordsModule: PlatformWebModule = {
id: "records",
label: "i18n:govoplan-records.records",
version: "0.1.18",
optionalDependencies: [
"files",
"cases",
"forms_runtime",
"decisions",
"campaigns",
"postbox",
"reporting",
"dms",
"policy",
"audit",
"search"
],
translations: generatedTranslations,
routes: [
{
path: "/records",
anyOf: ["records:workspace:read"],
order: 47,
surfaceId: "records.workspace",
render: (context) => createElement(RecordsPage, context)
}
],
navItems: [
{
to: "/records",
label: "i18n:govoplan-records.records",
iconName: "archive",
anyOf: ["records:workspace:read"],
order: 47,
surfaceId: "records.navigation"
}
],
viewSurfaces: [
{ id: "records.workspace.file-plan", moduleId: "records", kind: "section", label: "i18n:govoplan-records.file_plan", parentId: "records.workspace", order: 10 },
{ id: "records.workspace.list", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_list", parentId: "records.workspace", order: 20 },
{ id: "records.workspace.detail", moduleId: "records", kind: "section", label: "i18n:govoplan-records.record_detail", parentId: "records.workspace", order: 30 },
{ id: "records.workspace.file", moduleId: "records", kind: "action", label: "i18n:govoplan-records.file_source", parentId: "records.workspace.detail", order: 40 }
]
};
export default recordsModule;
+514
View File
@@ -0,0 +1,514 @@
.records-page,
.records-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.records-shell {
display: flex;
flex-direction: column;
background: var(--panel);
}
.records-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 9px 14px;
border-bottom: var(--border-line-dark);
background: var(--panel-header);
}
.records-search {
display: flex;
align-items: center;
gap: 8px;
width: min(420px, 100%);
margin-left: 8px;
}
.records-search input {
min-width: 130px;
height: 36px;
flex: 1;
}
.records-result-count {
margin-left: auto;
color: var(--muted);
font-size: var(--font-size-sm);
white-space: nowrap;
}
.records-shell > .alert {
margin: 10px 14px 0;
}
.records-workspace {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(360px, 0.8fr) minmax(420px, 1.25fr);
flex: 1;
min-height: 0;
overflow: hidden;
}
.records-file-plan,
.records-list-pane,
.records-detail-pane {
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--panel-soft);
}
.records-file-plan,
.records-list-pane {
border-right: var(--border-line-dark);
}
.records-file-plan,
.records-list-pane {
display: flex;
flex-direction: column;
}
.records-pane-heading {
display: flex;
align-items: center;
min-height: 52px;
padding: 8px 13px;
border-bottom: var(--border-line);
background: var(--panel);
}
.records-pane-heading > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.records-pane-heading span {
color: var(--muted);
font-size: var(--font-size-sm);
font-weight: 700;
}
.records-pane-heading strong {
overflow: hidden;
color: var(--text-strong);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.records-file-plan-scroll,
.records-detail-scroll {
flex: 1;
min-height: 0;
}
.records-file-plan-scroll {
padding: 7px;
}
.records-plan-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
padding: 7px 9px;
text-align: left;
}
.records-plan-row:hover,
.records-plan-row:focus-visible,
.records-plan-row.selected {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.records-plan-row.selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.records-plan-row > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-plan-code {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.records-list-grid {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px;
}
.records-list-grid > .data-grid-shell {
min-height: 100%;
border-radius: 0;
}
.records-record-link {
display: block;
width: 100%;
overflow: hidden;
border: 0;
background: transparent;
color: var(--text-strong);
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-record-link:hover,
.records-record-link:focus-visible,
.records-record-link.selected {
color: var(--accent);
outline: none;
text-decoration: underline;
}
.records-number {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.records-detail-pane {
display: flex;
flex-direction: column;
}
.records-detail-scroll {
padding: 18px 20px 32px;
}
.records-detail-empty {
display: grid;
place-items: center;
align-content: center;
gap: 10px;
height: 100%;
color: var(--muted);
padding: 30px;
text-align: center;
}
.records-detail-header,
.records-detail-actions,
.records-section-heading {
display: flex;
align-items: center;
}
.records-detail-header {
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 15px;
border-bottom: var(--border-line-dark);
}
.records-detail-header h1 {
margin: 3px 0 0;
font-size: 1.35rem;
letter-spacing: 0;
line-height: 1.25;
}
.records-eyebrow {
color: var(--muted);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.records-detail-actions {
flex: 0 0 auto;
gap: 8px;
}
.records-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin-top: 16px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-facts > div {
display: flex;
min-width: 0;
min-height: 64px;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 10px;
background: var(--surface);
}
.records-facts span:not(.status-badge),
.records-item-row span,
.records-item-row time,
.records-chronology span,
.records-chronology time {
color: var(--muted);
font-size: 12px;
}
.records-facts strong {
overflow-wrap: anywhere;
}
.records-description {
margin: 16px 0 0;
color: var(--text-soft);
line-height: 1.55;
}
.records-detail-section {
margin-top: 24px;
}
.records-section-heading {
justify-content: space-between;
gap: 12px;
min-height: 34px;
border-bottom: var(--border-line);
}
.records-section-heading h2 {
margin: 0;
font-size: 1rem;
letter-spacing: 0;
}
.records-section-heading > span {
display: inline-grid;
min-width: 24px;
height: 24px;
place-items: center;
border-radius: 50%;
background: var(--surface-strong);
color: var(--muted);
font-size: 12px;
}
.records-item-list {
border-bottom: var(--border-line);
}
.records-item-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 18px;
padding: 11px 5px;
border-bottom: var(--border-line);
}
.records-item-row:last-child {
border-bottom: 0;
}
.records-item-row > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.records-item-row > div:nth-child(2) {
align-items: end;
}
.records-item-row a,
.records-item-row strong {
overflow: hidden;
color: var(--text-strong);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-item-row p {
grid-column: 1 / -1;
margin: 2px 0 0;
color: var(--text-soft);
font-size: var(--font-size-sm);
}
.records-chronology > div {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
gap: 10px;
min-height: 52px;
padding: 10px 4px;
}
.records-chronology > div:not(:last-child)::before {
position: absolute;
top: 26px;
bottom: -10px;
left: 11px;
width: 1px;
background: var(--line-dark);
content: "";
}
.records-timeline-marker {
z-index: 1;
width: 9px;
height: 9px;
align-self: start;
margin: 5px 0 0 3px;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--panel-soft);
}
.records-chronology > div > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.records-chronology time {
white-space: nowrap;
}
.records-access-explanation > p {
color: var(--text-soft);
}
.records-access-explanation dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.records-access-explanation dl > div {
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.records-access-explanation dt {
color: var(--muted);
font-size: 12px;
}
.records-access-explanation dd {
margin: 4px 0 0;
font-weight: 700;
}
.records-empty-note {
margin: 12px;
color: var(--muted);
font-size: var(--font-size-sm);
}
.records-dialog {
width: min(760px, calc(100vw - 32px));
}
.records-dialog-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.records-dialog-form > .form-field:has(textarea) {
grid-column: 1 / -1;
}
@media (max-width: 1260px) {
.records-workspace {
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
}
.records-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.records-toolbar {
flex-wrap: wrap;
}
.records-search {
order: 5;
width: 100%;
margin-left: 0;
}
.records-workspace {
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
}
.records-detail-pane {
grid-column: 1 / -1;
min-height: 380px;
border-top: var(--border-line-dark);
}
}
@media (max-width: 640px) {
.records-workspace {
display: flex;
overflow-y: auto;
flex-direction: column;
}
.records-file-plan,
.records-list-pane,
.records-detail-pane {
min-height: 330px;
border-right: 0;
border-bottom: var(--border-line-dark);
}
.records-detail-header,
.records-detail-actions {
align-items: stretch;
flex-direction: column;
}
.records-facts,
.records-access-explanation dl,
.records-dialog-form {
grid-template-columns: 1fr;
}
.records-dialog-form > .form-field:has(textarea) {
grid-column: auto;
}
}