The overview now uses the same icon buttons as the circuit list editor: row actions are icon-only IconButtons (Pencil, Link2, Library, Trash2), card-header and panel actions show icon plus text, and the circuit-list link uses the new IconLink. The version history uses Undo2/Redo2, a chevron toggle and a RotateCcw restore action. Page-header buttons stay Bootstrap buttons with a lucide icon so the upcoming "Seitenansicht" button of feature/print-view merges without conflicts and matches them. The Verteilungen and Projektgeräte cards get a PM-style filter bar: search plus MultiSelectDropdown (checkbox list with "Alle", closes on outside click and Escape) and "Zurücksetzen" while a filter is active. Only the bar collapses; the state is kept per card in localStorage and a collapsed active filter shows "Filter aktiv". The header row shows "x von y", and an empty-state row explains when filters hide everything. The filter logic is a pure module (overview-filters.ts) with node tests; selections that are no longer offered (deleted floor) are ignored. IconButton now imports React explicitly like the other components that are rendered in node tests with the classic JSX runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
16 KiB
TypeScript
523 lines
16 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,
|
|
getProjectSnapshotRevisionDescription,
|
|
mergeProjectRevisionPages,
|
|
} from "../utils/project-version-history";
|
|
import {
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Redo2,
|
|
RotateCcw,
|
|
Undo2,
|
|
} from "lucide-react";
|
|
import { IconButton, IconButtonSeparator } from "./icon-button";
|
|
|
|
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>
|
|
<div className="overview-actions">
|
|
<IconButton
|
|
disabled={
|
|
isBusy ||
|
|
isHistoryLoading ||
|
|
!historyIsCurrent ||
|
|
!history?.undoDepth
|
|
}
|
|
icon={Undo2}
|
|
label="Rückgängig"
|
|
onClick={() => void handleHistoryCommand("undo")}
|
|
showLabel
|
|
title={
|
|
history?.undoDepth
|
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
|
: "Keine Änderung zum Rückgängigmachen"
|
|
}
|
|
/>
|
|
<IconButton
|
|
disabled={
|
|
isBusy ||
|
|
isHistoryLoading ||
|
|
!historyIsCurrent ||
|
|
!history?.redoDepth
|
|
}
|
|
icon={Redo2}
|
|
label="Wiederholen"
|
|
onClick={() => void handleHistoryCommand("redo")}
|
|
showLabel
|
|
title={
|
|
history?.redoDepth
|
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
|
: "Keine Änderung zum Wiederholen"
|
|
}
|
|
/>
|
|
<IconButtonSeparator />
|
|
<IconButton
|
|
ariaExpanded={isOpen}
|
|
icon={isOpen ? ChevronUp : ChevronDown}
|
|
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
|
onClick={() => setIsOpen((current) => !current)}
|
|
showLabel
|
|
/>
|
|
</div>
|
|
</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>Gesicherter 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>
|
|
<strong>
|
|
{snapshot.sourceRevision === 0
|
|
? "Ausgangsstand"
|
|
: `Revision ${snapshot.sourceRevision}`}
|
|
</strong>
|
|
<div className="small text-secondary">
|
|
{getProjectSnapshotRevisionDescription(snapshot)}
|
|
</div>
|
|
</td>
|
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
|
<td className="text-end">
|
|
<div className="overview-actions justify-content-end">
|
|
<IconButton
|
|
disabled={isBusy}
|
|
icon={RotateCcw}
|
|
label="Wiederherstellen"
|
|
onClick={() => setRestoreCandidate(snapshot)}
|
|
variant="danger"
|
|
/>
|
|
</div>
|
|
</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;
|
|
}
|