chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 0f2a9beca7
commit 970625edff
43 changed files with 2959 additions and 518 deletions

View File

@@ -0,0 +1,170 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, RefreshCw } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui";
import {
approveConfigurationChangeRequest,
fetchConfigurationChangesDelta,
type ConfigurationChangeRecord,
type ConfigurationChangeRequest } from
"../../api/admin";
const DELTA_KEY = "admin:configuration-changes";
export default function ConfigurationChangesPanel({ settings, canApprove }: {settings: ApiSettings;canApprove: boolean;}) {
const [requests, setRequests] = useState<ConfigurationChangeRequest[]>([]);
const [history, setHistory] = useState<ConfigurationChangeRecord[]>([]);
const requestsRef = useRef<ConfigurationChangeRequest[]>([]);
const historyRef = useRef<ConfigurationChangeRecord[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
async function load() {
setLoading(true);
setError("");
try {
let nextWatermark = getDeltaWatermark(DELTA_KEY);
let nextRequests = requestsRef.current;
let nextHistory = historyRef.current;
let hasMore = false;
do {
const payload = await fetchConfigurationChangesDelta(settings, { since: nextWatermark });
nextRequests = payload.full ? payload.requests : mergeDeltaRows(nextRequests, payload.requests, payload.deleted, (request) => request.id, { sort: sortConfigurationRequests });
nextHistory = payload.full ? payload.history : mergeDeltaRows(nextHistory, payload.history, payload.deleted, (record) => record.id, { sort: sortConfigurationHistory });
nextWatermark = payload.watermark ?? null;
hasMore = payload.has_more;
} while (hasMore);
requestsRef.current = nextRequests;
historyRef.current = nextHistory;
setRequests(nextRequests);
setHistory(nextHistory);
setDeltaWatermark(DELTA_KEY, nextWatermark);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
requestsRef.current = [];
historyRef.current = [];
resetDeltaWatermark(DELTA_KEY);
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
async function approve(request: ConfigurationChangeRequest) {
setBusyId(request.id);
setError("");
setMessage("");
try {
await approveConfigurationChangeRequest(settings, request.id);
setMessage(i18nMessage("i18n:govoplan-admin.approved_value.52da808b", { value0: request.key }));
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusyId("");
}
}
const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]);
return (
<AdminPageLayout
title="i18n:govoplan-admin.configuration_changes.82933bbb"
description="i18n:govoplan-admin.safety_controlled_configuration_requests_approva.e8259509"
loading={loading}
error={error}
success={message}
actions={<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}>
<Card title="i18n:govoplan-admin.requests.f7194e6a">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>i18n:govoplan-admin.setting.fb449f71</th>
<th>i18n:govoplan-admin.status.bae7d5be</th>
<th>i18n:govoplan-admin.requested.c26bf60f</th>
<th>i18n:govoplan-admin.approvals.deb9d03c</th>
<th>i18n:govoplan-admin.target.61ad50a9</th>
<th className="actions">i18n:govoplan-admin.action.97c89a4d</th>
</tr>
</thead>
<tbody>
{pending.map((request) =>
<tr key={request.id}>
<td><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></td>
<td><StatusBadge status={statusTone(request.status)} label={request.status} /></td>
<td>{formatDateTime(request.requested_at)}</td>
<td>{request.approvals.length}</td>
<td>{targetLabel(request.target)}</td>
<td className="actions">
{canApprove && request.status === "pending_approval" &&
<Button onClick={() => void approve(request)} disabled={busyId === request.id}><Check size={16} /> i18n:govoplan-admin.approve.7b2c7f14</Button>
}
</td>
</tr>
)}
{!pending.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_open_requests.580c95f9</td></tr>}
</tbody>
</table>
</div>
</Card>
<Card title="i18n:govoplan-admin.history.90ccd649">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>i18n:govoplan-admin.version.2da600bf</th>
<th>i18n:govoplan-admin.setting.fb449f71</th>
<th>i18n:govoplan-admin.status.bae7d5be</th>
<th>i18n:govoplan-admin.applied.a3e4a569</th>
<th>i18n:govoplan-admin.approvers.0e2de1fb</th>
<th>i18n:govoplan-admin.target.61ad50a9</th>
</tr>
</thead>
<tbody>
{history.map((record) =>
<tr key={record.id}>
<td>#{record.version}</td>
<td><strong>{record.key}</strong><span className="muted block">{record.id}</span></td>
<td><StatusBadge status={statusTone(record.status)} label={record.status} /></td>
<td>{formatDateTime(record.created_at)}</td>
<td>{record.approval_user_ids.length || "-"}</td>
<td>{targetLabel(record.target)}</td>
</tr>
)}
{!history.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7</td></tr>}
</tbody>
</table>
</div>
</Card>
</AdminPageLayout>);
}
function statusTone(status: string): string {
if (status === "approved" || status === "applied") return "success";
if (status === "pending_approval") return "warning";
if (status === "rejected") return "error";
return "inactive";
}
function targetLabel(target?: Record<string, unknown>): string {
if (!target || !Object.keys(target).length) return "-";
return Object.entries(target).map(([key, value]) => `${key}: ${String(value)}`).join(", ");
}
function sortConfigurationRequests(left: ConfigurationChangeRequest, right: ConfigurationChangeRequest): number {
return new Date(right.updated_at || right.requested_at).getTime() - new Date(left.updated_at || left.requested_at).getTime();
}
function sortConfigurationHistory(left: ConfigurationChangeRecord, right: ConfigurationChangeRecord): number {
return right.version - left.version;
}