Export and import single distribution boards as JSON

A board with its complete circuit list can be downloaded as a
leistungsbilanz.distribution-board file and imported into any project as
a new board. The import reuses distribution-board.insert-subtree, so it
is one revision with restart-safe Undo/Redo. All ids are new;
project-bound references (project devices with override metadata, room
ids, floor, logical feed, a disabled supply type and the Revit quantity
share) are dropped, circuit voltages follow the target project, and the
response lists these adjustments. The project page offers export per
board and import in the board card header.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 07:38:28 +01:00
parent 17a3945813
commit d0bd4fe7e0
11 changed files with 955 additions and 10 deletions

View file

@ -2,9 +2,10 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Download,
FileSpreadsheet,
Library,
Link2,
@ -15,6 +16,7 @@ import {
Settings,
Trash2,
Unlink,
Upload,
X,
} from "lucide-react";
import { IconButton } from "../../../frontend/components/icon-button";
@ -54,6 +56,7 @@ import {
deleteProjectDevice,
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportDistributionBoardTransfer,
exportProjectTransfer,
getProject,
getProjectCircuitTrees,
@ -65,6 +68,7 @@ import {
listGlobalDevices,
listProjectDevices,
listRooms,
importDistributionBoardTransfer,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
@ -214,6 +218,8 @@ export default function ProjectDetailPage() {
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const boardImportInputRef = useRef<HTMLInputElement | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
@ -766,6 +772,79 @@ export default function ProjectDetailPage() {
}
}
async function handleExportBoard(board: DistributionBoardDto) {
if (!projectId) {
return;
}
setError(null);
try {
const transfer = await exportDistributionBoardTransfer(projectId, board.id);
downloadJson(
transfer,
`${(board.boardNumber ?? board.name).replace(/[^a-zA-Z0-9_-]+/g, "-") || "verteiler"}.verteiler.json`
);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht exportiert werden."
);
}
}
// Imports an exported board file as a new board of this project.
async function handleImportBoardFile(file: File | undefined) {
if (boardImportInputRef.current) {
boardImportInputRef.current.value = "";
}
if (!file || !projectId || !project) {
return;
}
setError(null);
setNotice(null);
let transfer: unknown;
try {
transfer = JSON.parse(await file.text()) as unknown;
} catch {
setError("Die ausgewählte Datei enthält kein gültiges JSON.");
return;
}
const name = window.prompt(
"Bezeichnung der importierten Verteilung",
readTransferBoardName(transfer) ?? ""
);
if (name === null || !name.trim()) {
return;
}
setIsSaving(true);
try {
const result = await importDistributionBoardTransfer(
projectId,
transfer,
name.trim(),
project.currentRevision
);
setBoards((current) => [...current, result.distributionBoard]);
setCircuitLists((current) => [...current, result.circuitList]);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setNotice(
[
`Verteilung „${result.distributionBoard.name}“ importiert.`,
...result.adjustments,
].join(" ")
);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht importiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleExportProject() {
if (!project) {
return;
@ -774,15 +853,10 @@ export default function ProjectDetailPage() {
setError(null);
try {
const transfer = await exportProjectTransfer(project.id);
const blob = new Blob([JSON.stringify(transfer, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`;
anchor.click();
URL.revokeObjectURL(url);
downloadJson(
transfer,
`${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`
);
} catch (err) {
setError(
err instanceof Error
@ -1011,6 +1085,17 @@ export default function ProjectDetailPage() {
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
{notice ? (
<div className="alert alert-info d-flex justify-content-between align-items-start gap-3" role="status">
<span>{notice}</span>
<button
aria-label="Hinweis schließen"
className="btn-close"
onClick={() => setNotice(null)}
type="button"
/>
</div>
) : null}
<div className="kpi-grid">
<a className="kpi" href="#verteilungen">
@ -1054,6 +1139,23 @@ export default function ProjectDetailPage() {
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<div className="overview-actions">
<IconButton
disabled={isSaving}
icon={Upload}
label="Verteilung importieren"
onClick={() => boardImportInputRef.current?.click()}
showLabel
title="Exportierte Verteilerdatei (.verteiler.json) als neue Verteilung importieren"
/>
<input
accept=".json,application/json"
className="d-none"
onChange={(event) =>
void handleImportBoardFile(event.target.files?.[0])
}
ref={boardImportInputRef}
type="file"
/>
<IconButton
icon={Plus}
label="Verteilung hinzufügen"
@ -1153,6 +1255,11 @@ export default function ProjectDetailPage() {
label="Bearbeiten"
onClick={() => openBoardEditor(board)}
/>
<IconButton
icon={Download}
label="Verteilung exportieren"
onClick={() => void handleExportBoard(board)}
/>
{circuitList ? (
<IconLink
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
@ -1886,4 +1993,24 @@ export default function ProjectDetailPage() {
);
}
function downloadJson(value: unknown, filename: string) {
const blob = new Blob([JSON.stringify(value, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
// Board name of an exported board file, used as the import name proposal.
function readTransferBoardName(transfer: unknown): string | null {
if (typeof transfer !== "object" || transfer === null) {
return null;
}
const board = (transfer as { board?: { distributionBoard?: { name?: unknown } } })
.board?.distributionBoard;
return typeof board?.name === "string" ? board.name : null;
}