Expose project version history

This commit is contained in:
2026-07-25 22:56:51 +02:00
parent 384a5769ab
commit d00ae30bda
13 changed files with 759 additions and 10 deletions
+11
View File
@@ -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;
}
+43
View File
@@ -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;
+59
View File
@@ -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
);
}