Merge origin/main into feature/user-management

Renumber the users migration to 0013_users so it follows the upstream
0012_good_gateway (logical board feeds); AGENTS.md deferred-work list keeps
both the new feed items and the auth items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-28 21:46:21 +02:00
commit 95bfba2f67
63 changed files with 5546 additions and 443 deletions

View file

@ -22,6 +22,9 @@ export function deriveCircuitPhase(
* Rows without cos φ count as 1; a circuit without load yields 1.
*/
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
if (Number.isFinite(circuit.circuitCosPhi) && circuit.circuitCosPhi > 0) {
return Math.round(circuit.circuitCosPhi * 100) / 100;
}
let activePowerKw = 0;
let apparentPowerKva = 0;
for (const row of circuit.deviceRows) {

View file

@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
@ -3293,6 +3294,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
threePhaseVoltageV={data.threePhaseVoltageV}
/>
) : null}
{data.feedingReference ? (
<div className="alert alert-info py-2" role="status">
Gespeist von{" "}
<Link
href={`/projects/${projectId}/circuit-lists/${
data.feedingReference.circuitListId
}/tree-edit`}
>
{data.feedingReference.distributionBoardNumber ??
data.feedingReference.distributionBoardName}
{data.feedingReference.circuitEquipmentIdentifier ?? ""}
</Link>
{data.feedingReference.circuitId
? null
: " (speisender Stromkreis noch nicht festgelegt)"}
</div>
) : null}
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4062,6 +4080,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "summary-row"
: row.rowType === "deviceRow"
? "device-row"
: row.rowType === "downstreamBoardLoad"
? "downstream-board-load-row"
: row.rowType === "reserveCircuit"
? "empty-circuit-row"
: row.rowType === "placeholder"
@ -4667,7 +4687,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: ""
}`}
>
{row.circuit && row.rowType !== "deviceRow" ? (
{row.circuit &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit") ? (
<>
<IconButton
disabled={isSaving}

View file

@ -13,7 +13,7 @@ export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, stri
const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik",
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
fedFrom: "z. B. HAK, Zähleranlage, Trafo oder NEA",
remark: "",
};
@ -98,8 +98,8 @@ export function DistributionBoardInfoCard({
)}
{field === "fedFrom" ? (
<div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem
speisenden Verteiler folgt.
Für externe Einspeisungen oder ergänzende Hinweise. Eine
speisende Projektverteilung wird oben logisch verknüpft.
</div>
) : null}
</div>

View file

@ -19,14 +19,14 @@ export interface PrintBoardSummary {
circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */
devicePowerKw: number;
/** Device-row demand plus downstream-board demand before this board's GZF. */
powerBeforeBoardSimultaneityKw: number;
simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */
/** Total active power with this board's simultaneity factor applied. */
demandPowerKw: number;
/**
* Symmetrical three-phase design current from the apparent power
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
* Design current from the apparent power including downstream boards.
* The selected feeder circuit determines whether one or three phases apply.
*/
designCurrentA: number;
}
@ -46,8 +46,13 @@ export function summarizePrintBoard(input: {
circuitCount += 1;
for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
apparentPowerKva +=
circuit.circuitApparentPower ??
circuit.deviceRows.reduce(
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
0
);
}
}
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
@ -55,12 +60,13 @@ export function summarizePrintBoard(input: {
...input,
circuitCount,
installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower,
powerBeforeBoardSimultaneityKw: tree.distributionBoardTotalPower,
simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA:
(apparentPowerKva * simultaneityFactor * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV),
designCurrentA: calculateDesignCurrent(
apparentPowerKva * simultaneityFactor,
tree
),
};
}
@ -68,8 +74,6 @@ export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary;
depth: number;
parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
}
export function assertPrintRevisionConsistency(
@ -82,31 +86,21 @@ export function assertPrintRevisionConsistency(
}
}
/**
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
export function buildBoardHierarchy(
summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) =>
value?.trim().toLocaleLowerCase("de") || null;
const summaryById = new Map(
summaries.map((summary) => [summary.board.id, summary])
);
const parentById = new Map<string, string | null>();
for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom);
const candidates = fedFrom
? summaries.filter(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
const parentId = summary.board.feedingDistributionBoardId;
parentById.set(
summary.board.id,
parentId && parentId !== summary.board.id && summaryById.has(parentId)
? parentId
: null
);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
@ -125,20 +119,12 @@ export function buildPreliminaryBoardHierarchy(
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth,
parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
});
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
@ -146,6 +132,25 @@ export function buildPreliminaryBoardHierarchy(
return entries;
}
export function formatFeedingReference(summary: PrintBoardSummary): string {
const reference = summary.tree.feedingReference;
if (!reference) return summary.board.fedFrom ?? "–";
return `${
reference.distributionBoardNumber ?? reference.distributionBoardName
}${reference.circuitEquipmentIdentifier ?? ""}`;
}
function calculateDesignCurrent(
apparentPowerKva: number,
tree: CircuitTreeResponseDto
) {
const category = tree.feedingReference?.circuitCategory;
return category === "lighting" || category === "single_phase"
? (apparentPowerKva * 1000) / tree.singlePhaseVoltageV
: (apparentPowerKva * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV);
}
const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,

View file

@ -13,6 +13,7 @@ import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatFeedingReference,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
@ -269,8 +270,8 @@ function OverviewSheet({
stand: string;
}) {
const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped
// below that text as an external feed (e.g. "HAK / Zähleranlage").
// Root boards may still carry an external feed description such as
// "HAK / Zähleranlage"; logical board links determine the actual tree.
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) {
const feed = root.summary.board.fedFrom ?? "";
@ -320,11 +321,10 @@ function OverviewSheet({
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
<th className="pd-r">P vor Vert.-GZF<br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
<th className="pd-r">P<sub>gl</sub> inkl. UV<br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr>
</thead>
<tbody>
@ -340,41 +340,45 @@ function OverviewSheet({
<td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td>
<td>{formatFeedingReference(entry.summary)}</td>
<td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.powerBeforeBoardSimultaneityKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
<td colSpan={7}>Summe der Einspeisungen</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) =>
entry.parentBoardId === null
? entry.summary.powerBeforeBoardSimultaneityKw
: 0
))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) =>
entry.parentBoardId === null ? entry.summary.demandPowerKw : 0
))}</td>
<td />
</tr>
</tfoot>
</table>
<p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
Verknüpfung und Summierung folgt.
Die Struktur folgt den logischen Verknüpfungen. Eine Unterverteilung
wird im gewählten Abgang mit ihrer Leistung nach eigenem
Verteiler-GZF berücksichtigt. Ohne gewählten Abgang erscheint sie
nur in der Hierarchie.
</p>
<p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
S aus cos φ je Gerätezeile
P<sub>inst</sub> = lokale Geräte ohne GZF · P vor Vert.-GZF =
Geräte-GZF einschließlich gespeister Unterverteilungen · P<sub>gl</sub> =
zusätzlich mit Verteiler-GZF · I<sub>B</sub> aus S und der Phasenart
des speisenden Abgangs
</p>
</div>
</div>
@ -404,6 +408,7 @@ function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows;
const downstreamBoards = circuit.downstreamBoardLoads ?? [];
const circuitCells = (
<>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
@ -414,7 +419,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td>{circuit.remark ?? ""}</td>
</>
);
if (rows.length === 0) {
if (rows.length === 0 && downstreamBoards.length === 0) {
return (
<tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
@ -426,7 +431,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr>
);
}
if (rows.length === 1) {
if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
return (
@ -468,6 +473,16 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td colSpan={4} />
</tr>
))}
{downstreamBoards.map((load) => (
<tr className="pd-device" key={`downstream:${load.distributionBoardId}`}>
<td />
<td>Unterverteilung {load.boardNumber ?? load.name}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">{formatKilowatt(load.activePowerKw)}</td>
<td colSpan={4}>Automatisch aus der verknüpften Verteilung</td>
</tr>
))}
</>
);
}
@ -501,7 +516,7 @@ function BoardPlanSheet({
rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"],
["Gespeist von", formatFeedingReference(summary)],
[
<>P<sub>gl</sub></>,
<>
@ -542,7 +557,7 @@ function BoardPlanSheet({
<td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
<td className="pd-r">{formatKilowatt(summary.powerBeforeBoardSimultaneityKw)}</td>
<td colSpan={4} />
</tr>
<tr>

View file

@ -195,6 +195,8 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export interface DocumentLogoMetadataDto {
@ -442,7 +444,10 @@ export interface CircuitTreeCircuitDto {
isReserve: boolean;
remark?: string;
circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRowDto[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
@ -461,6 +466,11 @@ export interface CircuitTreeSectionDto {
export interface CircuitTreeResponseDto {
circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReferenceDto;
currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
@ -472,6 +482,32 @@ export interface CircuitTreeResponseDto {
footerComponents: CircuitTreeComponentDto[];
}
export interface CircuitTreeDownstreamBoardLoadDto {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReferenceDto {
distributionBoardId: string;
circuitListId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponseDto {
currentRevision: number;
trees: CircuitTreeResponseDto[];
}
export interface ExternalCircuitListObjectDto {
id: string;
ifcGuid: string;

View file

@ -33,6 +33,7 @@ import type {
ProjectDto,
RoomDto,
CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto,
ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
@ -372,7 +373,13 @@ export function updateDistributionBoard(
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
} & Partial<DistributionBoardInfoDto>,
} & Partial<DistributionBoardInfoDto> &
Partial<
Pick<
DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId"
>
>,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -523,6 +530,12 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function getProjectCircuitTrees(projectId: string) {
return request<ProjectCircuitTreesResponseDto>(
`/api/projects/${projectId}/circuit-trees`
);
}
export function insertDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,

View file

@ -44,6 +44,7 @@ export type RowType =
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "downstreamBoardLoad"
| "reserveCircuit"
| "placeholder"
| "footerComponent";
@ -614,6 +615,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
return "readonly";
}
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
if (rowType === "downstreamBoardLoad") return "readonly";
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
if (deviceFieldKeys.has(key)) return "deviceField";

View file

@ -2,6 +2,7 @@ import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
CircuitTreeDownstreamBoardLoadDto,
CircuitTreeSectionDto,
} from "../types.js";
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
@ -45,6 +46,7 @@ export interface VisibleGridRow {
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
component?: CircuitTreeComponentDto;
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto;
cells: VisibleGridCell[];
}
@ -126,13 +128,16 @@ function makeVisibleGridRow(
rowType: RowType,
sectionId: string,
circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto
device?: CircuitTreeDeviceRowDto,
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto
): VisibleGridRow {
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: rowType === "downstreamBoardLoad" && downstreamBoardLoad
? `downstream-board:${circuit?.id}:${downstreamBoardLoad.distributionBoardId}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((column): VisibleGridCell => {
@ -140,7 +145,17 @@ function makeVisibleGridRow(
const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue;
if (rowType === "placeholder") {
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
value = column.key === "displayName"
? `Unterverteilung ${downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name}`
: column.key === "rowTotalPower"
? downstreamBoardLoad.activePowerKw
: column.key === "cosPhi"
? downstreamBoardLoad.cosPhi
: column.key === "remark"
? "Automatisch aus der verknüpften Verteilung"
: undefined;
} else if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
value = getDeviceValue(device, column.key);
@ -166,7 +181,15 @@ function makeVisibleGridRow(
return { cellKey: column.key, editable, kind, value };
});
return { rowKey, rowType, sectionId, circuit, device, cells };
return {
rowKey,
rowType,
sectionId,
circuit,
device,
downstreamBoardLoad,
cells,
};
}
export function buildVisibleGridRowsWithStructure(
@ -221,15 +244,27 @@ export function buildVisibleGridRowsWithStructure(
}
if (row.rowType === "circuitBlock") {
const circuit = row.circuit;
if (circuit.deviceRows.length === 0) {
const downstreamLoads = circuit.downstreamBoardLoads ?? [];
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) {
} else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else {
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) {
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
}
for (const load of downstreamLoads) {
rows.push(
makeVisibleGridRow(
"downstreamBoardLoad",
section.id,
circuit,
undefined,
load
)
);
}
}
continue;
}