171 lines
7.3 KiB
TypeScript
171 lines
7.3 KiB
TypeScript
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;
|
|
}
|