feat(records): implement native eAkte vertical
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, recordsModule } from "./module";
|
||||
export * from "./api/records";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user