505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, {
|
|
FormEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
} from "react";
|
|
import type {
|
|
ProjectHistoryStateDto,
|
|
ProjectRevisionPageDto,
|
|
ProjectRevisionSummaryDto,
|
|
ProjectSnapshotMetadataDto,
|
|
} from "../types";
|
|
import {
|
|
createNamedProjectSnapshot,
|
|
getProjectHistory,
|
|
listProjectRevisions,
|
|
listProjectSnapshots,
|
|
redoProjectCommand,
|
|
restoreProjectSnapshot,
|
|
undoProjectCommand,
|
|
} from "../utils/api";
|
|
import {
|
|
getProjectRevisionDescription,
|
|
getProjectRevisionSourceLabel,
|
|
getProjectSnapshotKindLabel,
|
|
mergeProjectRevisionPages,
|
|
} from "../utils/project-version-history";
|
|
|
|
interface ProjectVersionHistoryProps {
|
|
projectId: string;
|
|
currentRevision: number;
|
|
onProjectStateChange: () => void;
|
|
}
|
|
|
|
const revisionPageSize = 10;
|
|
|
|
export function ProjectVersionHistory({
|
|
projectId,
|
|
currentRevision,
|
|
onProjectStateChange,
|
|
}: ProjectVersionHistoryProps) {
|
|
const [history, setHistory] =
|
|
useState<ProjectHistoryStateDto | null>(null);
|
|
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 [isHistoryLoading, setIsHistoryLoading] = useState(true);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
setIsHistoryLoading(true);
|
|
try {
|
|
setHistory(await getProjectHistory(projectId));
|
|
} catch (loadError) {
|
|
setError(
|
|
getVersionHistoryError(
|
|
loadError,
|
|
"Rückgängig-Status konnte nicht geladen werden."
|
|
)
|
|
);
|
|
} finally {
|
|
setIsHistoryLoading(false);
|
|
}
|
|
}, [projectId]);
|
|
|
|
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(() => {
|
|
void loadHistory();
|
|
}, [currentRevision, loadHistory]);
|
|
|
|
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);
|
|
onProjectStateChange();
|
|
} catch (restoreError) {
|
|
setError(
|
|
getVersionHistoryError(
|
|
restoreError,
|
|
"Projektstand konnte nicht wiederhergestellt werden."
|
|
)
|
|
);
|
|
setRestoreCandidate(null);
|
|
setIsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleHistoryCommand(direction: "undo" | "redo") {
|
|
setIsBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result =
|
|
direction === "undo"
|
|
? await undoProjectCommand(projectId, currentRevision)
|
|
: await redoProjectCommand(projectId, currentRevision);
|
|
setHistory(result.history);
|
|
onProjectStateChange();
|
|
} catch (historyError) {
|
|
setError(
|
|
getVersionHistoryError(
|
|
historyError,
|
|
direction === "undo"
|
|
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
|
: "Die letzte Änderung konnte nicht wiederholt werden."
|
|
)
|
|
);
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}
|
|
|
|
const historyIsCurrent =
|
|
history?.currentRevision === currentRevision;
|
|
|
|
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
|
|
className="btn btn-sm btn-outline-secondary"
|
|
disabled={
|
|
isBusy ||
|
|
isHistoryLoading ||
|
|
!historyIsCurrent ||
|
|
!history?.undoDepth
|
|
}
|
|
onClick={() => void handleHistoryCommand("undo")}
|
|
title={
|
|
history?.undoDepth
|
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
|
: "Keine Änderung zum Rückgängigmachen"
|
|
}
|
|
type="button"
|
|
>
|
|
Rückgängig
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
disabled={
|
|
isBusy ||
|
|
isHistoryLoading ||
|
|
!historyIsCurrent ||
|
|
!history?.redoDepth
|
|
}
|
|
onClick={() => void handleHistoryCommand("redo")}
|
|
title={
|
|
history?.redoDepth
|
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
|
: "Keine Änderung zum Wiederholen"
|
|
}
|
|
type="button"
|
|
>
|
|
Wiederholen
|
|
</button>
|
|
<button
|
|
aria-expanded={isOpen}
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => setIsOpen((current) => !current)}
|
|
type="button"
|
|
>
|
|
{isOpen ? "Ausblenden" : "Anzeigen"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error ? (
|
|
<div className="alert alert-warning m-3 mb-0" role="alert">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
{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>
|
|
|
|
<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>
|
|
<span
|
|
className={`badge ms-2 ${
|
|
snapshot.kind === "automatic"
|
|
? "text-bg-info"
|
|
: "text-bg-secondary"
|
|
}`}
|
|
>
|
|
{getProjectSnapshotKindLabel(snapshot.kind)}
|
|
</span>
|
|
{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;
|
|
}
|