leistungsbilanz-ts/src/frontend/components/project-version-history.tsx
Grovy311 837b29c7c5 Add icon actions and collapsible filters to the project overview
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>
2026-09-26 13:01:31 +02:00

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;
}