Expose project version history
This commit is contained in:
@@ -237,6 +237,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
project data atomically and records a new `restore` revision with a complete
|
||||
inverse command. Restore can therefore be undone and redone after a restart.
|
||||
The project page exposes an initially collapsed German snapshot/timeline UI
|
||||
with explicit restore confirmation and cursor-based loading of older revision
|
||||
metadata.
|
||||
Insertions and generated move targets use client-generated stable UUIDs, and
|
||||
undo restores the same ids from complete server snapshots. The tree response
|
||||
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
|
||||
@@ -308,7 +311,7 @@ Users must be able to override sizing suggestions.
|
||||
|
||||
## Current Deferred Work
|
||||
|
||||
- snapshot UI and periodic snapshot policy
|
||||
- periodic snapshot and retention policy
|
||||
- Revit/CSV/IFCGUID round-trip
|
||||
- full electrical sizing
|
||||
- multi-user/PostgreSQL operation
|
||||
|
||||
@@ -143,5 +143,5 @@ Covered operations include:
|
||||
- apply sorted order
|
||||
|
||||
The toolbar currently exposes availability through its Undo/Redo buttons.
|
||||
Named restore points can be created, listed and restored through the API. A
|
||||
browsable revision/snapshot UI remains future work.
|
||||
Named restore points and the revision timeline are available on the project
|
||||
page; the circuit editor keeps only the compact Undo/Redo controls.
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
persistent project commands and project-wide toolbar undo/redo, including
|
||||
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
||||
eligibility is restored from server history after a page reload.
|
||||
- A paginated revision-metadata API exists, but there is no browsable timeline
|
||||
UI yet; the editor currently exposes only the next eligible Undo/Redo
|
||||
actions.
|
||||
- The project page exposes the paginated revision timeline; the circuit editor
|
||||
itself currently exposes only the next eligible Undo/Redo actions.
|
||||
- Named logical project snapshots can be created, listed and restored through
|
||||
the API with persistent Undo/Redo. Snapshot UI and periodic snapshots are
|
||||
the project page with persistent Undo/Redo. Automatic periodic snapshots are
|
||||
not implemented yet.
|
||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||
|
||||
@@ -117,6 +117,11 @@ ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
||||
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
||||
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||
nicht Bestandteil des logischen Snapshots sind.
|
||||
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
|
||||
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
||||
Nach einem Restore lädt die Seite sämtliche Projektdaten neu.
|
||||
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
||||
|
||||
@@ -97,6 +97,9 @@ Implemented named-snapshot foundation:
|
||||
separately and are never imported into the logical snapshot payload
|
||||
- arbitrary restore payloads are rejected by the generic user-command
|
||||
endpoint; only server-stored snapshots can initiate a forward restore
|
||||
- the project page provides an initially collapsed snapshot/timeline panel,
|
||||
explicit inline restore confirmation and cursor-based loading of older
|
||||
revisions; a successful restore reloads all project read models
|
||||
|
||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||
|
||||
@@ -252,7 +255,7 @@ Completed foundation:
|
||||
|
||||
Remaining constraints before completing project version history:
|
||||
|
||||
- add a browsable timeline/snapshot UI and periodic snapshot policy
|
||||
- define and implement a periodic snapshot and retention policy
|
||||
- extend project-scoped commands only when further project mutations are
|
||||
deliberately added to history; the generic versioned envelope is complete
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
|
||||
@@ -371,6 +371,8 @@ Tasks:
|
||||
- add periodic logical project snapshots
|
||||
- [x] restore a historical snapshot as a new auditable revision with
|
||||
persistent undo/redo
|
||||
- [x] expose named snapshots, confirmed restore and the paginated revision
|
||||
timeline on the project page
|
||||
- keep database backups separate from logical history
|
||||
|
||||
Implemented foundation:
|
||||
@@ -397,6 +399,8 @@ Implemented foundation:
|
||||
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
||||
state with the source revision and SHA-256 without changing revision or
|
||||
undo/redo stacks
|
||||
- the project page lists, creates and explicitly restores named snapshots and
|
||||
displays paginated revision metadata without exposing command payloads
|
||||
- a central application dispatcher reconstructs persisted commands and exposes
|
||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||
field updates
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
||||
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
|
||||
import {
|
||||
createNamedProjectSnapshot,
|
||||
listProjectRevisions,
|
||||
listProjectSnapshots,
|
||||
restoreProjectSnapshot,
|
||||
} from "../src/frontend/utils/api.js";
|
||||
import {
|
||||
getProjectRevisionDescription,
|
||||
getProjectRevisionSourceLabel,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../src/frontend/utils/project-version-history.js";
|
||||
|
||||
function revision(
|
||||
revisionNumber: number,
|
||||
overrides: Partial<ProjectRevisionSummaryDto> = {}
|
||||
): ProjectRevisionSummaryDto {
|
||||
return {
|
||||
revisionId: `revision-${revisionNumber}`,
|
||||
changeSetId: `change-${revisionNumber}`,
|
||||
revisionNumber,
|
||||
createdAtIso: "2026-07-25T12:00:00.000Z",
|
||||
actorId: null,
|
||||
source: "user",
|
||||
description: null,
|
||||
commandType: "circuit.update",
|
||||
payloadSchemaVersion: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("project version history presentation", () => {
|
||||
it("renders a compact collapsed German entry point", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(ProjectVersionHistory, {
|
||||
projectId: "project-1",
|
||||
currentRevision: 42,
|
||||
onRestoreComplete: () => undefined,
|
||||
})
|
||||
);
|
||||
assert.match(markup, /Versionen und Sicherungspunkte/);
|
||||
assert.match(markup, /Aktuelle Revision 42/);
|
||||
assert.match(markup, /Anzeigen/);
|
||||
assert.doesNotMatch(markup, /Stand speichern/);
|
||||
});
|
||||
|
||||
it("uses German source and command descriptions", () => {
|
||||
assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung");
|
||||
assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig");
|
||||
assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt");
|
||||
assert.equal(
|
||||
getProjectRevisionSourceLabel("restore"),
|
||||
"Wiederherstellung"
|
||||
);
|
||||
assert.equal(getProjectRevisionSourceLabel("migration"), "Migration");
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(revision(1)),
|
||||
"Stromkreis bearbeitet"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(2, { description: " Eigene Beschreibung " })
|
||||
),
|
||||
"Eigene Beschreibung"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(3, { commandType: "future.command" })
|
||||
),
|
||||
"future.command"
|
||||
);
|
||||
});
|
||||
|
||||
it("merges paginated revisions without duplicates in descending order", () => {
|
||||
assert.deepEqual(
|
||||
mergeProjectRevisionPages(
|
||||
[revision(5), revision(4)],
|
||||
[revision(4), revision(3)]
|
||||
).map((entry) => entry.revisionNumber),
|
||||
[5, 4, 3]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project version history API", () => {
|
||||
it("uses the revision-safe timeline and snapshot routes", async () => {
|
||||
const requests: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await listProjectRevisions("project-1", {
|
||||
limit: 10,
|
||||
beforeRevision: 21,
|
||||
});
|
||||
await listProjectSnapshots("project-1");
|
||||
await createNamedProjectSnapshot(
|
||||
"project-1",
|
||||
22,
|
||||
"Planfreigabe",
|
||||
"Vor Ausführung"
|
||||
);
|
||||
await restoreProjectSnapshot("project-1", "snapshot-1", 22);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
|
||||
method: "GET",
|
||||
body: null,
|
||||
},
|
||||
{
|
||||
url: "/api/projects/project-1/snapshots",
|
||||
method: "GET",
|
||||
body: null,
|
||||
},
|
||||
{
|
||||
url: "/api/projects/project-1/snapshots",
|
||||
method: "POST",
|
||||
body: {
|
||||
expectedRevision: 22,
|
||||
name: "Planfreigabe",
|
||||
description: "Vor Ausführung",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "/api/projects/project-1/snapshots/snapshot-1/restore",
|
||||
method: "POST",
|
||||
body: { expectedRevision: 22 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user