|
|
|
@@ -0,0 +1,252 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
Button,
|
|
|
|
|
Card,
|
|
|
|
|
ConfirmDialog,
|
|
|
|
|
DataGrid,
|
|
|
|
|
Dialog,
|
|
|
|
|
DismissibleAlert,
|
|
|
|
|
FormField,
|
|
|
|
|
LoadingFrame,
|
|
|
|
|
MetricCard,
|
|
|
|
|
StatusBadge,
|
|
|
|
|
TableActionGroup,
|
|
|
|
|
adminErrorMessage,
|
|
|
|
|
hasAnyScope,
|
|
|
|
|
type ApiSettings,
|
|
|
|
|
type AuthInfo,
|
|
|
|
|
type DataGridColumn
|
|
|
|
|
} from "@govoplan/core-webui";
|
|
|
|
|
import {
|
|
|
|
|
applyOrganizationModelUpgrade,
|
|
|
|
|
cancelOrganizationModelUpgrade,
|
|
|
|
|
getOrganizationModelUpgrades,
|
|
|
|
|
getOrganizationTemplateCatalog,
|
|
|
|
|
previewOrganizationModelUpgrade,
|
|
|
|
|
type OrganizationModelInstantiation,
|
|
|
|
|
type OrganizationModelUpgrade,
|
|
|
|
|
type OrganizationTemplateCatalogItem,
|
|
|
|
|
type OrganizationTemplateVersion,
|
|
|
|
|
type OrganizationUpgradeDecisionAction,
|
|
|
|
|
type OrganizationUpgradeDiffEntry
|
|
|
|
|
} from "../../api/organizations";
|
|
|
|
|
|
|
|
|
|
type Decision = { action: OrganizationUpgradeDecisionAction; target_key?: string };
|
|
|
|
|
|
|
|
|
|
export default function OrganizationTemplateUpgradePanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
|
|
|
const canRead = hasAnyScope(auth, ["organizations:model:read", "admin:settings:read"]);
|
|
|
|
|
const canWrite = hasAnyScope(auth, ["organizations:model:write"]);
|
|
|
|
|
const [templates, setTemplates] = useState<OrganizationTemplateCatalogItem[]>([]);
|
|
|
|
|
const [current, setCurrent] = useState<OrganizationModelInstantiation | null>(null);
|
|
|
|
|
const [upgrades, setUpgrades] = useState<OrganizationModelUpgrade[]>([]);
|
|
|
|
|
const [targetVersionId, setTargetVersionId] = useState("");
|
|
|
|
|
const [selected, setSelected] = useState<OrganizationModelUpgrade | null>(null);
|
|
|
|
|
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
|
|
|
|
|
const [changeRequestId, setChangeRequestId] = useState("");
|
|
|
|
|
const [applyConfirmation, setApplyConfirmation] = useState(false);
|
|
|
|
|
const [cancelTarget, setCancelTarget] = useState<OrganizationModelUpgrade | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
const [success, setSuccess] = useState("");
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
if (!canRead) return;
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError("");
|
|
|
|
|
try {
|
|
|
|
|
const [catalog, state] = await Promise.all([
|
|
|
|
|
getOrganizationTemplateCatalog(settings),
|
|
|
|
|
getOrganizationModelUpgrades(settings)
|
|
|
|
|
]);
|
|
|
|
|
setTemplates(catalog.templates);
|
|
|
|
|
setCurrent(state.current_instantiation ?? null);
|
|
|
|
|
setUpgrades(state.upgrades);
|
|
|
|
|
const available = availableVersions(catalog.templates, state.current_instantiation ?? null);
|
|
|
|
|
setTargetVersionId((value) => available.some((item) => item.id === value) ? value : available[0]?.id ?? "");
|
|
|
|
|
setSelected((value) => value ? state.upgrades.find((item) => item.id === value.id) ?? null : null);
|
|
|
|
|
} catch (caught) {
|
|
|
|
|
setError(adminErrorMessage(caught));
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [canRead, settings.accessToken, settings.apiBaseUrl]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
|
|
|
|
|
|
const versions = useMemo(() => versionMap(templates), [templates]);
|
|
|
|
|
const targets = useMemo(() => availableVersions(templates, current), [templates, current]);
|
|
|
|
|
const currentVersion = current ? versions.get(current.template_version_id) : undefined;
|
|
|
|
|
const pending = upgrades.filter((item) => item.status === "previewed");
|
|
|
|
|
|
|
|
|
|
const upgradeColumns = useMemo<DataGridColumn<OrganizationModelUpgrade>[]>(() => [
|
|
|
|
|
{ id: "source", header: "Source version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id, value: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id },
|
|
|
|
|
{ id: "target", header: "Target version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id, value: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id },
|
|
|
|
|
{ id: "changes", header: "Changes", width: 105, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.entries.length, value: (row) => row.preview.entries.length },
|
|
|
|
|
{ id: "decisions", header: "Decisions", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.requires_decisions, value: (row) => row.preview.requires_decisions },
|
|
|
|
|
{ id: "invalid", header: "Invalid refs", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.blocking_invalid_references, value: (row) => row.preview.blocking_invalid_references },
|
|
|
|
|
{ id: "status", header: "Status", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
|
|
|
|
|
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
|
|
|
|
|
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
|
|
|
|
|
{ id: "inspect", label: "Inspect upgrade", icon: <Eye aria-hidden="true" />, onClick: () => openUpgrade(row) },
|
|
|
|
|
{ id: "cancel", label: "Cancel preview", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canWrite || row.status !== "previewed", onClick: () => setCancelTarget(row) }
|
|
|
|
|
]} /> }
|
|
|
|
|
], [canWrite, versions]);
|
|
|
|
|
|
|
|
|
|
const diffColumns = useMemo<DataGridColumn<OrganizationUpgradeDiffEntry>[]>(() => [
|
|
|
|
|
{ id: "collection", header: "Area", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.collection), value: (row) => row.collection },
|
|
|
|
|
{ id: "key", header: "Object", width: 210, sortable: true, filterable: true, render: (row) => row.key, value: (row) => row.key },
|
|
|
|
|
{ id: "classification", header: "Classification", width: 185, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.classification} />, value: (row) => row.classification },
|
|
|
|
|
{ id: "summary", header: "Comparison", width: 330, render: (row) => comparisonSummary(row), value: (row) => comparisonSummary(row) },
|
|
|
|
|
{ id: "decision", header: "Decision", width: 280, render: (row) => row.requires_decision ? <div className="organization-upgrade-decision"><select value={decisions[row.id]?.action ?? ""} disabled={!canWrite || busy || selected?.status !== "previewed"} onChange={(event) => setDecision(row, event.target.value as OrganizationUpgradeDecisionAction)}><option value="">Select decision</option>{row.allowed_actions.map((action) => <option key={action} value={action}>{decisionLabel(action)}</option>)}</select>{decisions[row.id]?.action === "map_to" && <input value={decisions[row.id]?.target_key ?? ""} placeholder="Target key" disabled={!canWrite || busy} onChange={(event) => setDecisions((value) => ({ ...value, [row.id]: { ...value[row.id], target_key: event.target.value } }))} />}</div> : "Automatic", value: (row) => decisions[row.id]?.action ?? "automatic" }
|
|
|
|
|
], [busy, canWrite, decisions, selected?.status]);
|
|
|
|
|
|
|
|
|
|
function setDecision(entry: OrganizationUpgradeDiffEntry, action: OrganizationUpgradeDecisionAction) {
|
|
|
|
|
if (!action) {
|
|
|
|
|
setDecisions((value) => { const next = { ...value }; delete next[entry.id]; return next; });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setDecisions((value) => ({ ...value, [entry.id]: { action } }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openUpgrade(upgrade: OrganizationModelUpgrade) {
|
|
|
|
|
setSelected(upgrade);
|
|
|
|
|
setDecisions(upgrade.decisions ?? {});
|
|
|
|
|
setChangeRequestId("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createPreview() {
|
|
|
|
|
if (!targetVersionId || busy) return;
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError("");
|
|
|
|
|
setSuccess("");
|
|
|
|
|
try {
|
|
|
|
|
const created = await previewOrganizationModelUpgrade(settings, targetVersionId);
|
|
|
|
|
setSuccess("The three-way comparison was recorded. No tenant model data was changed.");
|
|
|
|
|
await load();
|
|
|
|
|
openUpgrade(created);
|
|
|
|
|
} catch (caught) {
|
|
|
|
|
setError(adminErrorMessage(caught));
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyUpgrade() {
|
|
|
|
|
if (!selected || busy) return;
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError("");
|
|
|
|
|
setSuccess("");
|
|
|
|
|
try {
|
|
|
|
|
await applyOrganizationModelUpgrade(settings, selected.id, selected.revision, decisions, changeRequestId.trim() || undefined);
|
|
|
|
|
setApplyConfirmation(false);
|
|
|
|
|
setSelected(null);
|
|
|
|
|
setSuccess("The template upgrade was applied as a new tenant-owned model instantiation.");
|
|
|
|
|
await load();
|
|
|
|
|
} catch (caught) {
|
|
|
|
|
setError(adminErrorMessage(caught));
|
|
|
|
|
setApplyConfirmation(false);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cancelUpgrade() {
|
|
|
|
|
if (!cancelTarget || busy) return;
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError("");
|
|
|
|
|
try {
|
|
|
|
|
await cancelOrganizationModelUpgrade(settings, cancelTarget.id, cancelTarget.revision);
|
|
|
|
|
setCancelTarget(null);
|
|
|
|
|
if (selected?.id === cancelTarget.id) setSelected(null);
|
|
|
|
|
setSuccess("The upgrade preview was cancelled without changing the tenant model.");
|
|
|
|
|
await load();
|
|
|
|
|
} catch (caught) {
|
|
|
|
|
setError(adminErrorMessage(caught));
|
|
|
|
|
setCancelTarget(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const decisionsComplete = selected ? selected.preview.entries.every((entry) => !entry.requires_decision || Boolean(decisions[entry.id]?.action) && (decisions[entry.id].action !== "map_to" || Boolean(decisions[entry.id].target_key?.trim()))) : false;
|
|
|
|
|
const applyDisabled = !selected || !canWrite || busy || selected.status !== "previewed" || selected.preview.blocking_invalid_references > 0 || !decisionsComplete;
|
|
|
|
|
|
|
|
|
|
if (!canRead) return null;
|
|
|
|
|
return <>
|
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
|
|
|
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
|
|
|
|
<Card title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
|
|
|
|
|
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
|
|
|
|
|
<div className="metric-grid compact">
|
|
|
|
|
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
|
|
|
|
|
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
|
|
|
|
|
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
|
|
|
|
|
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
|
|
|
|
|
<div className="organization-upgrade-toolbar">
|
|
|
|
|
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
|
|
|
|
|
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
|
|
|
|
|
</LoadingFrame>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
|
|
|
|
|
{selected && <>
|
|
|
|
|
<div className="metric-grid compact">
|
|
|
|
|
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
|
|
|
|
|
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
|
|
|
|
|
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
|
|
|
|
|
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
|
|
|
|
|
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
|
|
|
|
|
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
|
|
|
|
|
</>}
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<ConfirmDialog open={applyConfirmation} title="Apply organization model upgrade" message="Apply this reviewed comparison and its explicit decisions? The current instantiation will be superseded, the resulting model remains tenant-owned, and the operation is recorded for audit and event consumers." confirmLabel="Apply upgrade" busy={busy} onConfirm={() => void applyUpgrade()} onCancel={() => !busy && setApplyConfirmation(false)} />
|
|
|
|
|
<ConfirmDialog open={Boolean(cancelTarget)} title="Cancel organization model upgrade" message="Cancel this preview? No tenant organization data will be changed and the cancellation remains recorded in the upgrade history." confirmLabel="Cancel preview" tone="danger" busy={busy} onConfirm={() => void cancelUpgrade()} onCancel={() => !busy && setCancelTarget(null)} />
|
|
|
|
|
</>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function versionMap(templates: OrganizationTemplateCatalogItem[]): Map<string, OrganizationTemplateVersion> {
|
|
|
|
|
return new Map(templates.flatMap((template) => template.versions.map((version) => [version.id, version] as const)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function availableVersions(templates: OrganizationTemplateCatalogItem[], current: OrganizationModelInstantiation | null): OrganizationTemplateVersion[] {
|
|
|
|
|
if (!current) return [];
|
|
|
|
|
const template = templates.find((item) => item.id === current.template_id);
|
|
|
|
|
return (template?.versions ?? []).filter((version) => version.id !== current.template_version_id && version.status === "published");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function templateVersionLabel(templates: OrganizationTemplateCatalogItem[], version: OrganizationTemplateVersion): string {
|
|
|
|
|
const template = templates.find((item) => item.id === version.template_id);
|
|
|
|
|
return `${template?.name ?? "Template"} · ${version.version}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function comparisonSummary(entry: OrganizationUpgradeDiffEntry): string {
|
|
|
|
|
if (entry.classification === "compatible_addition") return "Added by target template";
|
|
|
|
|
if (entry.classification === "compatible_change") return "Target changed; tenant still matches source";
|
|
|
|
|
if (entry.classification === "destructive_remapping") return "Removal or reference remapping";
|
|
|
|
|
if (entry.classification === "invalid_reference") return String(entry.local?.diagnostic ?? "Invalid tenant reference");
|
|
|
|
|
return entry.requires_decision ? "Both tenant and target changed" : "Tenant-only customization is preserved";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decisionLabel(action: OrganizationUpgradeDecisionAction): string {
|
|
|
|
|
if (action === "keep_local") return "Keep tenant value";
|
|
|
|
|
if (action === "use_target") return "Use template value";
|
|
|
|
|
return "Map references to another key";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function humanize(value: string): string {
|
|
|
|
|
return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDateTime(value: string): string {
|
|
|
|
|
const parsed = new Date(value);
|
|
|
|
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
|
|
|
|
}
|