Expose project version history
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../../shared/constants/project-device-sync-fields";
|
||||
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
|
||||
|
||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||
name: "Technischer Name",
|
||||
@@ -608,6 +609,16 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{project ? (
|
||||
<section className="col-12">
|
||||
<ProjectVersionHistory
|
||||
currentRevision={project.currentRevision}
|
||||
onRestoreComplete={() => window.location.reload()}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="col-12 col-lg-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Neue Verteilung</div>
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
ProjectRevisionPageDto,
|
||||
ProjectRevisionSummaryDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../types";
|
||||
import {
|
||||
createNamedProjectSnapshot,
|
||||
listProjectRevisions,
|
||||
listProjectSnapshots,
|
||||
restoreProjectSnapshot,
|
||||
} from "../utils/api";
|
||||
import {
|
||||
getProjectRevisionDescription,
|
||||
getProjectRevisionSourceLabel,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
|
||||
interface ProjectVersionHistoryProps {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
onRestoreComplete: () => void;
|
||||
}
|
||||
|
||||
const revisionPageSize = 10;
|
||||
|
||||
export function ProjectVersionHistory({
|
||||
projectId,
|
||||
currentRevision,
|
||||
onRestoreComplete,
|
||||
}: ProjectVersionHistoryProps) {
|
||||
const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>(
|
||||
[]
|
||||
);
|
||||
const [revisions, setRevisions] = useState<ProjectRevisionSummaryDto[]>(
|
||||
[]
|
||||
);
|
||||
const [nextBeforeRevision, setNextBeforeRevision] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [snapshotName, setSnapshotName] = useState("");
|
||||
const [snapshotDescription, setSnapshotDescription] = useState("");
|
||||
const [restoreCandidate, setRestoreCandidate] =
|
||||
useState<ProjectSnapshotMetadataDto | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [loadedSnapshots, revisionPage] = await Promise.all([
|
||||
listProjectSnapshots(projectId),
|
||||
listProjectRevisions(projectId, { limit: revisionPageSize }),
|
||||
]);
|
||||
setSnapshots(loadedSnapshots);
|
||||
setRevisions(revisionPage.revisions);
|
||||
setNextBeforeRevision(revisionPage.nextBeforeRevision);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
getVersionHistoryError(
|
||||
loadError,
|
||||
"Versionshistorie konnte nicht geladen werden."
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadOverview();
|
||||
}
|
||||
}, [loadOverview, currentRevision, isOpen]);
|
||||
|
||||
async function handleCreateSnapshot(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = snapshotName.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createNamedProjectSnapshot(
|
||||
projectId,
|
||||
currentRevision,
|
||||
name,
|
||||
snapshotDescription.trim() || undefined
|
||||
);
|
||||
setSnapshots((current) => [created, ...current]);
|
||||
setSnapshotName("");
|
||||
setSnapshotDescription("");
|
||||
} catch (createError) {
|
||||
setError(
|
||||
getVersionHistoryError(
|
||||
createError,
|
||||
"Projektstand konnte nicht gespeichert werden."
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadOlderRevisions() {
|
||||
if (nextBeforeRevision === null) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const page: ProjectRevisionPageDto = await listProjectRevisions(
|
||||
projectId,
|
||||
{
|
||||
limit: revisionPageSize,
|
||||
beforeRevision: nextBeforeRevision,
|
||||
}
|
||||
);
|
||||
setRevisions((current) =>
|
||||
mergeProjectRevisionPages(current, page.revisions)
|
||||
);
|
||||
setNextBeforeRevision(page.nextBeforeRevision);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
getVersionHistoryError(
|
||||
loadError,
|
||||
"Ältere Revisionen konnten nicht geladen werden."
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestoreSnapshot() {
|
||||
if (!restoreCandidate) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await restoreProjectSnapshot(
|
||||
projectId,
|
||||
restoreCandidate.id,
|
||||
currentRevision
|
||||
);
|
||||
setRestoreCandidate(null);
|
||||
setIsBusy(false);
|
||||
onRestoreComplete();
|
||||
} catch (restoreError) {
|
||||
setError(
|
||||
getVersionHistoryError(
|
||||
restoreError,
|
||||
"Projektstand konnte nicht wiederhergestellt werden."
|
||||
)
|
||||
);
|
||||
setRestoreCandidate(null);
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<span>Versionen und Sicherungspunkte</span>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="badge text-bg-secondary">
|
||||
Aktuelle Revision {currentRevision}
|
||||
</span>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div className="card-body border-bottom">
|
||||
<h2 className="h6">Neuen Sicherungspunkt anlegen</h2>
|
||||
<p className="small text-secondary">
|
||||
Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne
|
||||
die Revision oder Rückgängig-Historie zu verändern.
|
||||
</p>
|
||||
<form className="row g-2" onSubmit={handleCreateSnapshot}>
|
||||
<div className="col-12 col-lg-4">
|
||||
<label className="form-label" htmlFor="snapshot-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="snapshot-name"
|
||||
maxLength={100}
|
||||
placeholder="z. B. Vor Planfreigabe"
|
||||
value={snapshotName}
|
||||
onChange={(event) => setSnapshotName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<label className="form-label" htmlFor="snapshot-description">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="snapshot-description"
|
||||
maxLength={500}
|
||||
placeholder="Was kennzeichnet diesen Stand?"
|
||||
value={snapshotDescription}
|
||||
onChange={(event) =>
|
||||
setSnapshotDescription(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-lg-2 d-flex align-items-end">
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
disabled={isBusy || !snapshotName.trim()}
|
||||
type="submit"
|
||||
>
|
||||
Stand speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning m-3 mb-0" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-body border-bottom">
|
||||
<h2 className="h6">Gespeicherte Sicherungspunkte</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-secondary mb-0">Sicherungspunkte werden geladen …</p>
|
||||
) : snapshots.length ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Projektstand</th>
|
||||
<th>Erstellt</th>
|
||||
<th className="text-end">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td>
|
||||
<strong>{snapshot.name}</strong>
|
||||
{snapshot.description ? (
|
||||
<div className="small text-secondary">
|
||||
{snapshot.description}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>Revision {snapshot.sourceRevision}</td>
|
||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
disabled={isBusy}
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
type="button"
|
||||
>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-secondary mb-0">
|
||||
Noch keine Sicherungspunkte vorhanden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{restoreCandidate ? (
|
||||
<div className="alert alert-danger mt-3 mb-0" role="alert">
|
||||
<h3 className="h6">
|
||||
„{restoreCandidate.name}“ wirklich wiederherstellen?
|
||||
</h3>
|
||||
<p className="mb-2">
|
||||
Verteiler, Stromkreise, Projektgeräte, Räume und
|
||||
Projekteinstellungen werden auf Revision{" "}
|
||||
{restoreCandidate.sourceRevision} zurückgesetzt. Der aktuelle
|
||||
Stand bleibt als neue rückgängig machbare Revision erhalten.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={isBusy}
|
||||
onClick={() => void handleRestoreSnapshot()}
|
||||
type="button"
|
||||
>
|
||||
Jetzt wiederherstellen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => setRestoreCandidate(null)}
|
||||
type="button"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<h2 className="h6">Letzte Änderungen</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-secondary mb-0">Historie wird geladen …</p>
|
||||
) : revisions.length ? (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Revision</th>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Art</th>
|
||||
<th>Änderung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision) => (
|
||||
<tr key={revision.revisionId}>
|
||||
<td>{revision.revisionNumber}</td>
|
||||
<td>{formatDateTime(revision.createdAtIso)}</td>
|
||||
<td>
|
||||
{getProjectRevisionSourceLabel(revision.source)}
|
||||
</td>
|
||||
<td>{getProjectRevisionDescription(revision)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{nextBeforeRevision !== null ? (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary mt-3"
|
||||
disabled={isBusy}
|
||||
onClick={() => void handleLoadOlderRevisions()}
|
||||
type="button"
|
||||
>
|
||||
Ältere Änderungen laden
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-secondary mb-0">
|
||||
Für dieses Projekt wurden noch keine versionierten Änderungen
|
||||
gespeichert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getVersionHistoryError(error: unknown, fallback: string) {
|
||||
const details = error instanceof Error ? error.message : "";
|
||||
if (details.includes("PROJECT_SNAPSHOT_NAME_CONFLICT")) {
|
||||
return "Ein Sicherungspunkt mit diesem Namen ist bereits vorhanden.";
|
||||
}
|
||||
if (
|
||||
details.includes("PROJECT_REVISION_CONFLICT") ||
|
||||
details.includes("PROJECT_STATE_CONFLICT")
|
||||
) {
|
||||
return "Das Projekt wurde inzwischen geändert. Bitte lade die Seite neu und versuche es erneut.";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -15,6 +15,49 @@ export interface ProjectHistoryStateDto {
|
||||
redoChangeSetId: string | null;
|
||||
}
|
||||
|
||||
export type ProjectRevisionSourceDto =
|
||||
| "user"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "restore"
|
||||
| "migration";
|
||||
|
||||
export interface ProjectRevisionSummaryDto {
|
||||
revisionId: string;
|
||||
changeSetId: string;
|
||||
revisionNumber: number;
|
||||
createdAtIso: string;
|
||||
actorId: string | null;
|
||||
source: ProjectRevisionSourceDto;
|
||||
description: string | null;
|
||||
commandType: string;
|
||||
payloadSchemaVersion: number;
|
||||
}
|
||||
|
||||
export interface ProjectRevisionPageDto {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
revisions: ProjectRevisionSummaryDto[];
|
||||
nextBeforeRevision: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectSnapshotMetadataDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceRevision: number;
|
||||
schemaVersion: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
payloadSha256: string;
|
||||
createdAtIso: string;
|
||||
createdByActorId: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectSnapshotRestoreResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
snapshot: ProjectSnapshotMetadataDto;
|
||||
}
|
||||
|
||||
export interface ProjectCommandDto {
|
||||
schemaVersion: number;
|
||||
type: string;
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
ProjectDeviceCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectHistoryStateDto,
|
||||
ProjectRevisionPageDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
ProjectSnapshotRestoreResultDto,
|
||||
ProjectDeviceSyncCommandResultDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDto,
|
||||
@@ -82,6 +85,62 @@ export function getProjectHistory(projectId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listProjectRevisions(
|
||||
projectId: string,
|
||||
input: { limit?: number; beforeRevision?: number } = {}
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (input.limit !== undefined) {
|
||||
query.set("limit", String(input.limit));
|
||||
}
|
||||
if (input.beforeRevision !== undefined) {
|
||||
query.set("beforeRevision", String(input.beforeRevision));
|
||||
}
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return request<ProjectRevisionPageDto>(
|
||||
`/api/projects/${projectId}/history/revisions${suffix}`
|
||||
);
|
||||
}
|
||||
|
||||
export function listProjectSnapshots(projectId: string) {
|
||||
return request<ProjectSnapshotMetadataDto[]>(
|
||||
`/api/projects/${projectId}/snapshots`
|
||||
);
|
||||
}
|
||||
|
||||
export function createNamedProjectSnapshot(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
name: string,
|
||||
description?: string
|
||||
) {
|
||||
return request<ProjectSnapshotMetadataDto>(
|
||||
`/api/projects/${projectId}/snapshots`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expectedRevision,
|
||||
name,
|
||||
...(description ? { description } : {}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreProjectSnapshot(
|
||||
projectId: string,
|
||||
snapshotId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectSnapshotRestoreResultDto>(
|
||||
`/api/projects/${projectId}/snapshots/${snapshotId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function executeProjectCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
ProjectRevisionSourceDto,
|
||||
ProjectRevisionSummaryDto,
|
||||
} from "../types";
|
||||
|
||||
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
|
||||
user: "Bearbeitung",
|
||||
undo: "Rückgängig",
|
||||
redo: "Wiederholt",
|
||||
restore: "Wiederherstellung",
|
||||
migration: "Migration",
|
||||
};
|
||||
|
||||
const commandTypeLabels: Record<string, string> = {
|
||||
"circuit.update": "Stromkreis bearbeitet",
|
||||
"circuit.insert": "Stromkreis angelegt",
|
||||
"circuit.delete": "Stromkreis gelöscht",
|
||||
"circuit-device-row.update": "Gerätezeile bearbeitet",
|
||||
"circuit-device-row.insert": "Gerätezeile angelegt",
|
||||
"circuit-device-row.delete": "Gerätezeile gelöscht",
|
||||
"circuit-device-row.move": "Gerätezeile verschoben",
|
||||
"circuit-device-row.move-with-new-circuit":
|
||||
"Gerätezeile in neuen Stromkreis verschoben",
|
||||
"circuit.reorder-section": "Stromkreise sortiert",
|
||||
"circuit.reorder-sections": "Stromkreise bereichsübergreifend sortiert",
|
||||
"circuit.renumber-section": "Bereich neu nummeriert",
|
||||
"project-device.update": "Projektgerät bearbeitet",
|
||||
"project-device.insert": "Projektgerät angelegt",
|
||||
"project-device.delete": "Projektgerät gelöscht",
|
||||
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
||||
"project.restore-state": "Projektstand wiederhergestellt",
|
||||
};
|
||||
|
||||
export function getProjectRevisionSourceLabel(
|
||||
source: ProjectRevisionSourceDto
|
||||
) {
|
||||
return sourceLabels[source];
|
||||
}
|
||||
|
||||
export function getProjectRevisionDescription(
|
||||
revision: ProjectRevisionSummaryDto
|
||||
) {
|
||||
return (
|
||||
revision.description?.trim() ||
|
||||
commandTypeLabels[revision.commandType] ||
|
||||
revision.commandType
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeProjectRevisionPages(
|
||||
current: ProjectRevisionSummaryDto[],
|
||||
next: ProjectRevisionSummaryDto[]
|
||||
) {
|
||||
const byId = new Map(
|
||||
[...current, ...next].map((revision) => [
|
||||
revision.revisionId,
|
||||
revision,
|
||||
])
|
||||
);
|
||||
return [...byId.values()].sort(
|
||||
(left, right) => right.revisionNumber - left.revisionNumber
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user