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

View file

@ -0,0 +1,190 @@
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
type ProjectVoltageSettings,
} from "../services/project-voltage.service.js";
import {
assertDistributionBoardSubtreeSnapshot,
cloneDistributionBoardSubtree,
normalizeDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "./distribution-board-subtree-snapshot.model.js";
// Portable file of one distribution board (board, circuit list, groups,
// circuits, device rows, components and protection). It is imported as a
// new board through distribution-board.insert-subtree, so the import is one
// undoable revision.
export const distributionBoardTransferFormat =
"leistungsbilanz.distribution-board" as const;
export const distributionBoardTransferFormatVersion = 1 as const;
export interface DistributionBoardTransferEnvelope {
format: typeof distributionBoardTransferFormat;
formatVersion: typeof distributionBoardTransferFormatVersion;
exportedAtIso: string;
sourceProjectName: string;
board: DistributionBoardSubtreeSnapshot;
}
export interface DistributionBoardImportTarget {
projectId: string;
name?: string;
voltageSettings: ProjectVoltageSettings;
enabledSupplyTypes: readonly DistributionBoardSupplyType[];
}
export interface PreparedDistributionBoardImport {
snapshot: DistributionBoardSubtreeSnapshot;
/** German notes on project-specific data that was not carried over. */
adjustments: string[];
}
export function createDistributionBoardTransferEnvelope(
board: DistributionBoardSubtreeSnapshot,
sourceProjectName: string,
exportedAtIso: string
): DistributionBoardTransferEnvelope {
return parseDistributionBoardTransferEnvelope({
format: distributionBoardTransferFormat,
formatVersion: distributionBoardTransferFormatVersion,
exportedAtIso,
sourceProjectName,
board,
});
}
export function parseDistributionBoardTransferEnvelope(
value: unknown
): DistributionBoardTransferEnvelope {
if (!isPlainObject(value) || value.format !== distributionBoardTransferFormat) {
throw new Error("Die Datei ist keine Verteiler-Exportdatei.");
}
if (value.formatVersion !== distributionBoardTransferFormatVersion) {
throw new Error("Die Version der Verteiler-Exportdatei wird nicht unterstützt.");
}
if (Object.keys(value).length !== 5) {
throw new Error("Die Verteiler-Exportdatei enthält unbekannte Felder.");
}
if (
typeof value.exportedAtIso !== "string" ||
!Number.isFinite(Date.parse(value.exportedAtIso)) ||
typeof value.sourceProjectName !== "string"
) {
throw new Error("Die Kopfdaten der Verteiler-Exportdatei sind ungültig.");
}
let board: DistributionBoardSubtreeSnapshot;
try {
assertDistributionBoardSubtreeSnapshot(value.board);
board = normalizeDistributionBoardSubtreeSnapshot(value.board);
} catch {
throw new Error("Der Verteiler in der Exportdatei ist ungültig.");
}
return {
format: distributionBoardTransferFormat,
formatVersion: distributionBoardTransferFormatVersion,
exportedAtIso: value.exportedAtIso,
sourceProjectName: value.sourceProjectName,
board,
};
}
/**
* Builds the board for the target project: every id is new, and references
* that only exist in the source project are removed. Project-device links,
* room links, floor and feed references are dropped (room number and name
* stay as text), quantities keep only their manual share because Revit
* objects are not part of the file, circuit voltages follow the target
* project, and a supply type the target project has disabled is cleared.
*/
export function prepareDistributionBoardImport(
envelope: DistributionBoardTransferEnvelope,
target: DistributionBoardImportTarget,
createId?: () => string
): PreparedDistributionBoardImport {
const source = envelope.board;
const name = target.name?.trim() || source.distributionBoard.name;
const clone = cloneDistributionBoardSubtree(source, name, createId);
const rows = clone.circuits.flatMap((circuit) => circuit.deviceRows);
const linkedRowCount = rows.filter((row) => row.linkedProjectDeviceId !== null).length;
const roomRowCount = rows.filter((row) => row.roomId !== null).length;
const externalQuantityRowCount = rows.filter(
(row) => (row.manualQuantity ?? row.quantity) !== row.quantity
).length;
const sectionKeyById = new Map(
clone.sections.map((section) => [section.id, section.key])
);
const supplyType =
clone.distributionBoard.supplyType !== null &&
target.enabledSupplyTypes.includes(clone.distributionBoard.supplyType)
? clone.distributionBoard.supplyType
: null;
const snapshot: DistributionBoardSubtreeSnapshot = {
...clone,
distributionBoard: {
...clone.distributionBoard,
projectId: target.projectId,
floorId: null,
supplyType,
feedingDistributionBoardId: null,
feedingCircuitId: null,
},
circuitList: { ...clone.circuitList, projectId: target.projectId },
circuits: clone.circuits.map((circuit) => ({
...circuit,
voltage: resolveProjectVoltage(
resolveCircuitPhaseType(
sectionKeyById.get(circuit.sectionId) ?? "",
circuit.deviceRows.map((row) => row.phaseType)
),
target.voltageSettings
),
deviceRows: circuit.deviceRows.map((row) => {
const manualQuantity = row.manualQuantity ?? row.quantity;
return {
...row,
linkedProjectDeviceId: null,
overriddenFields: null,
roomId: null,
quantity: manualQuantity,
manualQuantity,
};
}),
})),
};
assertDistributionBoardSubtreeSnapshot(snapshot);
const adjustments: string[] = [];
if (linkedRowCount > 0) {
adjustments.push(
`${linkedRowCount} Gerätezeile(n) sind nicht mehr mit Projektgeräten verknüpft.`
);
}
if (roomRowCount > 0) {
adjustments.push(
`${roomRowCount} Raumzuordnung(en) wurden als Raumnummer/-name (Text) übernommen.`
);
}
if (externalQuantityRowCount > 0) {
adjustments.push(
`${externalQuantityRowCount} Gerätezeile(n) enthalten nur noch die manuelle Anzahl (ohne Revit-Anteil).`
);
}
if (source.distributionBoard.floorId !== null) {
adjustments.push("Die Etage ist nicht übernommen und muss neu zugeordnet werden.");
}
if (source.distributionBoard.feedingDistributionBoardId !== null) {
adjustments.push("Die Einspeisung aus einem anderen Verteiler ist nicht übernommen.");
}
if (source.distributionBoard.supplyType !== null && supplyType === null) {
adjustments.push(
"Die Netzart ist in diesem Projekt nicht freigegeben und wurde entfernt."
);
}
return { snapshot, adjustments };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -312,6 +312,12 @@ export interface DistributionBoardCopyCommandResultDto
circuitList: CircuitListDto;
}
export interface DistributionBoardImportCommandResultDto
extends DistributionBoardCopyCommandResultDto {
/** Notes on source-project data that the import did not carry over. */
adjustments: string[];
}
export interface DistributionBoardDeleteCommandResultDto
extends ProjectCommandResultDto {
distributionBoardId: string;

View file

@ -13,6 +13,7 @@ import type {
DistributionBoardDto,
DistributionBoardCommandResultDto,
DistributionBoardCopyCommandResultDto,
DistributionBoardImportCommandResultDto,
DistributionBoardDeleteCommandResultDto,
DistributionBoardInfoDto,
DocumentLogoMetadataDto,
@ -633,6 +634,30 @@ export function copyDistributionBoard(
);
}
export function exportDistributionBoardTransfer(
projectId: string,
distributionBoardId: string
) {
return request<unknown>(
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}/export`
);
}
export function importDistributionBoardTransfer(
projectId: string,
transfer: unknown,
name: string,
expectedRevision: number
) {
return request<DistributionBoardImportCommandResultDto>(
`/api/projects/${projectId}/distribution-boards/import`,
{
method: "POST",
body: JSON.stringify({ expectedRevision, name, transfer }),
}
);
}
export function deleteDistributionBoard(
projectId: string,
distributionBoardId: string,

View file

@ -0,0 +1,116 @@
import type { Request, Response } from "express";
import { createDistributionBoardInsertSubtreeProjectCommand } from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
createDistributionBoardTransferEnvelope,
parseDistributionBoardTransferEnvelope,
prepareDistributionBoardImport,
} from "../../domain/models/distribution-board-transfer.model.js";
import { importDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
import { projectRepository } from "../composition/application-repositories.js";
import {
distributionBoardSubtreeProjectCommandStore,
projectCommandService,
} from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
// Downloads one board with its complete circuit list as a portable file.
export async function exportDistributionBoard(req: Request, res: Response) {
const { projectId, distributionBoardId } = req.params;
if (
typeof projectId !== "string" ||
typeof distributionBoardId !== "string" ||
!projectId.trim() ||
!distributionBoardId.trim()
) {
return res.status(400).json({ error: "Invalid distribution board id" });
}
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
let board;
try {
board = distributionBoardSubtreeProjectCommandStore.capture(
projectId,
distributionBoardId
);
} catch {
return res.status(404).json({ error: "Distribution board not found" });
}
const transfer = createDistributionBoardTransferEnvelope(
board,
project.name,
new Date().toISOString()
);
const filename = safeFilename(
board.distributionBoard.boardNumber ?? board.distributionBoard.name
);
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}.verteiler.json"`
);
return res.json(transfer);
}
// Inserts the board of an exported file as a new board of this project.
export async function importDistributionBoard(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = importDistributionBoardSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
let prepared;
try {
prepared = prepareDistributionBoardImport(
parseDistributionBoardTransferEnvelope(parsed.data.transfer),
{
projectId,
name: parsed.data.name,
voltageSettings: project,
enabledSupplyTypes: project.enabledDistributionBoardSupplyTypes,
}
);
} catch (error) {
return res.status(400).json({
error:
error instanceof Error
? error.message
: "Die Verteilerdatei ist ungültig.",
});
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Verteilung importieren",
command: createDistributionBoardInsertSubtreeProjectCommand(
prepared.snapshot
),
});
return res.status(201).json({
...result,
distributionBoard: prepared.snapshot.distributionBoard,
circuitList: prepared.snapshot.circuitList,
adjustments: prepared.adjustments,
});
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
function safeFilename(name: string) {
return (
name
.normalize("NFKD")
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "verteiler"
);
}

View file

@ -65,6 +65,10 @@ import {
} from "../controllers/external-initial-import.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
import {
exportDistributionBoard,
importDistributionBoard,
} from "../controllers/distribution-board-transfer.controller.js";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
export const projectRouter = Router();
@ -110,6 +114,14 @@ projectRouter.post(
"/:projectId/distribution-boards/:distributionBoardId/copy",
copyDistributionBoard
);
projectRouter.post(
"/:projectId/distribution-boards/import",
importDistributionBoard
);
projectRouter.get(
"/:projectId/distribution-boards/:distributionBoardId/export",
exportDistributionBoard
);
projectRouter.put(
"/:projectId/distribution-boards/:distributionBoardId",
updateDistributionBoard

View file

@ -98,6 +98,17 @@ export const copyDistributionBoardSchema = z
})
.strict();
// The file itself is checked by parseDistributionBoardTransferEnvelope.
export const importDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200).optional(),
transfer: z.unknown().refine((value) => value !== undefined, {
message: "Verteilerdatei fehlt.",
}),
})
.strict();
export const deleteDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,