Standardize phase type labels

This commit is contained in:
Julian Appel 2026-07-29 10:46:00 +02:00
parent 084103bf54
commit 096ba8aa1c
23 changed files with 6117 additions and 52 deletions

View file

@ -37,6 +37,9 @@ The pure grid modules have no React state and are covered by focused unit tests.
- `cosPhi` - `cosPhi`
- room snapshots - room snapshots
- category/cost group and related row attributes - category/cost group and related row attributes
- canonical `phaseType` values `single_phase` or `three_phase`; the
frontend always presents these as `1-phasig` or `3-phasig` and edits
them through a selection control
- `ProjectDevice` - `ProjectDevice`
- Reusable device template entity at project level. - Reusable device template entity at project level.
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert. - Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.

View file

@ -689,7 +689,8 @@ body {
border-color: #c2410c; border-color: #c2410c;
} }
.tree-grid input { .tree-grid input,
.tree-grid select {
width: 100%; width: 100%;
min-width: 5rem; min-width: 5rem;
border: 1px solid #9fb6e0; border: 1px solid #9fb6e0;

View file

@ -193,7 +193,7 @@ export default function ProjectsPage() {
async function handleQuickUpdateGlobalDevice( async function handleQuickUpdateGlobalDevice(
device: GlobalDeviceDto, device: GlobalDeviceDto,
key: "name" | "displayName" | "category", key: "name" | "displayName" | "category" | "phaseCount",
value: string value: string
) { ) {
const payload: CreateGlobalDeviceInput = { const payload: CreateGlobalDeviceInput = {
@ -203,7 +203,12 @@ export default function ProjectsPage() {
quantity: device.quantity, quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw, installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor, demandFactor: device.demandFactor,
phaseCount: device.phaseCount ?? undefined, phaseCount:
key === "phaseCount"
? value === "3"
? 3
: 1
: device.phaseCount ?? 1,
powerFactor: device.powerFactor ?? undefined, powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined, note: device.note ?? undefined,
}; };
@ -416,8 +421,8 @@ export default function ProjectsPage() {
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value })) setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
} }
> >
<option value="1">1-ph</option> <option value="1">1-phasig</option>
<option value="3">3-ph</option> <option value="3">3-phasig</option>
</select> </select>
</div> </div>
<div className="col-6 col-md-2"> <div className="col-6 col-md-2">
@ -437,6 +442,7 @@ export default function ProjectsPage() {
<th>Anzahl</th> <th>Anzahl</th>
<th>Leistung je Stück [kW]</th> <th>Leistung je Stück [kW]</th>
<th>GZF</th> <th>GZF</th>
<th>Phasenart</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
@ -479,6 +485,23 @@ export default function ProjectsPage() {
<td>{device.quantity}</td> <td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td> <td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td> <td>{device.demandFactor}</td>
<td>
<select
className="form-select form-select-sm"
aria-label={`Phasenart für ${device.displayName}`}
value={String(device.phaseCount ?? 1)}
onChange={(event) =>
void handleQuickUpdateGlobalDevice(
device,
"phaseCount",
event.target.value
)
}
>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</td>
<td> <td>
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
@ -492,7 +515,7 @@ export default function ProjectsPage() {
))} ))}
{!globalDevices.length ? ( {!globalDevices.length ? (
<tr> <tr>
<td colSpan={7} className="text-center text-secondary py-4"> <td colSpan={8} className="text-center text-secondary py-4">
Noch keine globalen Geräte vorhanden. Noch keine globalen Geräte vorhanden.
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,85 @@
UPDATE `project_devices`
SET `phase_type` = CASE
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
THEN 'single_phase'
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
THEN 'three_phase'
ELSE `phase_type`
END
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN (
'1ph',
'1phase',
'1phasig',
'einphasig',
'singlephase',
'3ph',
'3phase',
'3phasig',
'dreiphasig',
'threephase'
);
--> statement-breakpoint
UPDATE `circuit_device_rows`
SET `phase_type` = CASE
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
THEN 'single_phase'
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
THEN 'three_phase'
ELSE `phase_type`
END
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
IN (
'1ph',
'1phase',
'1phasig',
'einphasig',
'singlephase',
'3ph',
'3phase',
'3phasig',
'dreiphasig',
'threephase'
);
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `single_phase_row`
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
AND `single_phase_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);

View file

@ -0,0 +1,75 @@
UPDATE `circuit_device_rows`
SET `phase_type` = COALESCE(
(
SELECT `pd`.`phase_type`
FROM `project_devices` AS `pd`
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
),
(
SELECT CASE
WHEN `cs`.`key` = 'three_phase' THEN 'three_phase'
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN 'single_phase'
ELSE NULL
END
FROM `circuits` AS `c`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
)
)
WHERE `phase_type` IS NULL
AND (
EXISTS (
SELECT 1
FROM `project_devices` AS `pd`
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
)
OR EXISTS (
SELECT 1
FROM `circuits` AS `c`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
AND `cs`.`key` IN ('lighting', 'single_phase', 'three_phase')
)
);
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `single_phase_row`
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
AND `single_phase_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);

View file

@ -0,0 +1,7 @@
UPDATE `circuit_device_rows`
SET `phase_type` = 'single_phase'
WHERE `phase_type` IS NULL;
--> statement-breakpoint
UPDATE `global_devices`
SET `phase_count` = 1
WHERE `phase_count` IS NULL OR `phase_count` NOT IN (1, 3);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -141,6 +141,27 @@
"when": 1785310907349, "when": 1785310907349,
"tag": "0019_normalize_project_voltages", "tag": "0019_normalize_project_voltages",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1785314248007,
"tag": "0020_normalize_phase_types",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1785314558555,
"tag": "0021_fill_missing_phase_types",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1785314626476,
"tag": "0022_default_remaining_phase_types",
"breakpoints": true
} }
] ]
} }

View file

@ -11,6 +11,7 @@ import type {
ExecuteCircuitDeviceRowUpdateCommandInput, ExecuteCircuitDeviceRowUpdateCommandInput,
} from "../../domain/ports/circuit-device-row-project-command.store.js"; } from "../../domain/ports/circuit-device-row-project-command.store.js";
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js"; import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
@ -81,6 +82,13 @@ export class CircuitDeviceRowProjectCommandRepository
change.value, change.value,
]) ])
) as CircuitDeviceRowPatchInput; ) as CircuitDeviceRowPatchInput;
if (
input.source === "user" &&
patch.phaseType !== undefined &&
!isElectricalPhaseType(patch.phaseType)
) {
throw new Error("Device-row phase type is invalid.");
}
this.assertLinkedProjectDevice(tx, input.projectId, patch); this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch); this.assertRoom(tx, input.projectId, patch);

View file

@ -14,6 +14,7 @@ import type {
ExecuteCircuitDeviceRowStructureCommandInput, ExecuteCircuitDeviceRowStructureCommandInput,
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js"; } from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
@ -81,6 +82,9 @@ export class CircuitDeviceRowStructureProjectCommandRepository
"User commands cannot assign legacy consumer ids." "User commands cannot assign legacy consumer ids."
); );
} }
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
this.assertCircuitInProject(database, projectId, row.circuitId); this.assertCircuitInProject(database, projectId, row.circuitId);
assertCircuitDeviceRowReferencesInProject(database, projectId, row); assertCircuitDeviceRowReferencesInProject(database, projectId, row);

View file

@ -13,6 +13,7 @@ import type {
CircuitStructureProjectCommandStore, CircuitStructureProjectCommandStore,
ExecuteCircuitStructureCommandInput, ExecuteCircuitStructureCommandInput,
} from "../../domain/ports/circuit-structure-project-command.store.js"; } from "../../domain/ports/circuit-structure-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
@ -136,6 +137,9 @@ export class CircuitStructureProjectCommandRepository
"User commands cannot assign legacy consumer ids." "User commands cannot assign legacy consumer ids."
); );
} }
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
assertCircuitDeviceRowReferencesInProject( assertCircuitDeviceRowReferencesInProject(
database, database,
projectId, projectId,

View file

@ -27,7 +27,7 @@ export class GlobalDeviceRepository {
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
voltageV: null, voltageV: null,
phaseCount: input.phaseCount ?? null, phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null, powerFactor: input.powerFactor ?? null,
note: input.note ?? null, note: input.note ?? null,
}); });
@ -46,7 +46,7 @@ export class GlobalDeviceRepository {
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
voltageV: null, voltageV: null,
phaseCount: input.phaseCount ?? null, phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null, powerFactor: input.powerFactor ?? null,
note: input.note ?? null, note: input.note ?? null,
}) })

View file

@ -6,6 +6,7 @@ import {
import { import {
resolveCircuitPhaseType, resolveCircuitPhaseType,
resolveProjectVoltage, resolveProjectVoltage,
normalizeKnownElectricalPhaseType,
} from "../services/project-voltage.service.js"; } from "../services/project-voltage.service.js";
export const legacyProjectStateSnapshotSchemaVersion = 1 as const; export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
@ -225,7 +226,7 @@ export function parseProjectStateSnapshot(
value: unknown value: unknown
): ProjectStateSnapshot { ): ProjectStateSnapshot {
const version = isPlainObject(value) ? value.schemaVersion : undefined; const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot = const parsedSnapshot =
version === legacyProjectStateSnapshotSchemaVersion version === legacyProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot( ? upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot( upgradeDistributionBoardProjectStateSnapshot(
@ -255,10 +256,57 @@ export function parseProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value) supplyTypesProjectStateSnapshotSchema.parse(value)
) )
: projectStateSnapshotSchema.parse(value); : projectStateSnapshotSchema.parse(value);
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
assertProjectStateSnapshotRelations(snapshot); assertProjectStateSnapshotRelations(snapshot);
return snapshot; return snapshot;
} }
function normalizeSnapshotPhaseTypes(
snapshot: ProjectStateSnapshot
): ProjectStateSnapshot {
const sectionById = new Map(
snapshot.circuitSections.map((section) => [section.id, section])
);
const projectDeviceById = new Map(
snapshot.projectDevices.map((device) => [device.id, device])
);
return {
...snapshot,
circuits: snapshot.circuits.map((circuit) => {
const section = sectionById.get(circuit.sectionId);
const deviceRows = circuit.deviceRows.map((row) => {
const normalizedPhaseType =
normalizeKnownElectricalPhaseType(row.phaseType);
const linkedPhaseType = row.linkedProjectDeviceId
? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType
: undefined;
return {
...row,
phaseType:
normalizedPhaseType ??
linkedPhaseType ??
(section?.key === "three_phase"
? "three_phase"
: "single_phase"),
};
});
return {
...circuit,
voltage: section
? resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
deviceRows.map((row) => row.phaseType)
),
snapshot.project
)
: circuit.voltage,
deviceRows,
};
}),
};
}
function upgradeLegacyProjectStateSnapshot( function upgradeLegacyProjectStateSnapshot(
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema> snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
): z.infer<typeof previousProjectStateSnapshotSchema> { ): z.infer<typeof previousProjectStateSnapshotSchema> {

View file

@ -1,5 +1,39 @@
export type ElectricalPhaseType = "single_phase" | "three_phase"; export type ElectricalPhaseType = "single_phase" | "three_phase";
export function isElectricalPhaseType(
value: unknown
): value is ElectricalPhaseType {
return value === "single_phase" || value === "three_phase";
}
export function normalizeKnownElectricalPhaseType(
value: string | null
): ElectricalPhaseType | null {
if (value === null) {
return null;
}
const normalized = value
.trim()
.toLocaleLowerCase("de-DE")
.replaceAll("-", "")
.replaceAll("_", "");
if (
["1ph", "1phase", "1phasig", "einphasig", "singlephase"].includes(
normalized
)
) {
return "single_phase";
}
if (
["3ph", "3phase", "3phasig", "dreiphasig", "threephase"].includes(
normalized
)
) {
return "three_phase";
}
throw new Error(`Unknown electrical phase type: ${value}`);
}
export interface ProjectVoltageSettings { export interface ProjectVoltageSettings {
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
@ -25,8 +59,7 @@ export function resolveCircuitPhaseType(
return "single_phase"; return "single_phase";
} }
const assignedPhaseTypes = devicePhaseTypes.filter( const assignedPhaseTypes = devicePhaseTypes.filter(
(phaseType): phaseType is ElectricalPhaseType => isElectricalPhaseType
phaseType === "single_phase" || phaseType === "three_phase"
); );
return assignedPhaseTypes.length > 0 && return assignedPhaseTypes.length > 0 &&
assignedPhaseTypes.every((phaseType) => phaseType === "three_phase") assignedPhaseTypes.every((phaseType) => phaseType === "three_phase")

View file

@ -25,6 +25,7 @@ import {
buildDeviceRowEditPatch, buildDeviceRowEditPatch,
defaultVisibleColumnKeys, defaultVisibleColumnKeys,
deviceFieldKeys, deviceFieldKeys,
formatPhaseTypeLabel,
formatValue, formatValue,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
import { import {
@ -153,16 +154,6 @@ function normalizeUiError(err: unknown): string {
return message; return message;
} }
function formatPhaseTypeLabel(value: string): string {
if (value === "three_phase") {
return "Dreiphasig";
}
if (value === "single_phase") {
return "Einphasig";
}
return value;
}
function isPrintableKey(event: KeyboardEvent<HTMLElement>) { function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey; return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
} }
@ -235,7 +226,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null); const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null); const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | HTMLSelectElement | null>(null);
const focusTokenRef = useRef(1); const focusTokenRef = useRef(1);
const projectRevisionRef = useRef<number | null>(null); const projectRevisionRef = useRef<number | null>(null);
@ -449,7 +440,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</button> </button>
) : null} ) : null}
{activeColumnFilters.map(({ column, values }) => { {activeColumnFilters.map(({ column, values }) => {
const shownValues = values.slice(0, 2).join(", "); const shownValues = values
.slice(0, 2)
.map((value) =>
column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value
)
.join(", ");
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues; const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
return ( return (
<button <button
@ -1213,9 +1211,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
inputRef.current.focus(); inputRef.current.focus();
if (editingCell.mode === "selectExisting") { if (
inputRef.current instanceof HTMLInputElement &&
editingCell.mode === "selectExisting"
) {
inputRef.current.select(); inputRef.current.select();
} else { } else if (inputRef.current instanceof HTMLInputElement) {
const len = inputRef.current.value.length; const len = inputRef.current.value.length;
inputRef.current.setSelectionRange(len, len); inputRef.current.setSelectionRange(len, len);
} }
@ -1245,7 +1246,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
// Spreadsheet behavior: typing on a selected cell starts overwrite mode. // Spreadsheet behavior: typing on a selected cell starts overwrite mode.
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? ""); const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? ""
: String(visibleCell.value ?? "");
focusTokenRef.current += 1; focusTokenRef.current += 1;
setEditingCell({ setEditingCell({
...cell, ...cell,
@ -1384,7 +1388,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
// Commits the active editor input through command history so edits remain undoable. // Commits the active editor input through command history so edits remain undoable.
async function commitEdit(direction: SaveDirection = "stay") { async function commitEdit(
direction: SaveDirection = "stay",
draftOverride?: string
) {
if (!editingCell) { if (!editingCell) {
return; return;
} }
@ -1394,10 +1401,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setEditingCell(null); setEditingCell(null);
return; return;
} }
const draft = draftOverride ?? editingCell.draft;
if ( if (
editingCell.cellKey === "equipmentIdentifier" && editingCell.cellKey === "equipmentIdentifier" &&
row.circuit && row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft) hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) { ) {
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert."); setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
return; return;
@ -1424,7 +1432,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Aus freier Zeile erstellen", label: "Aus freier Zeile erstellen",
redo: async () => { redo: async () => {
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); const selection = await createFromPlaceholder(
row.sectionId,
editingCell.cellKey,
draft
);
targetSelection = selection; targetSelection = selection;
return nextSelectionIntent(); return nextSelectionIntent();
}, },
@ -1435,7 +1447,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (cell.kind === "circuitField" && row.circuit) { if (cell.kind === "circuitField" && row.circuit) {
const next = editingCell.draft; const next = draft;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten", label: "Stromkreisfeld bearbeiten",
redo: async () => { redo: async () => {
@ -1449,7 +1461,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (cell.kind === "deviceField" && row.device) { if (cell.kind === "deviceField" && row.device) {
const next = editingCell.draft; const next = draft;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätefeld bearbeiten", label: "Gerätefeld bearbeiten",
redo: async () => { redo: async () => {
@ -1463,7 +1475,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) { if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
const next = editingCell.draft; const next = draft;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen", label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => { redo: async () => {
@ -2816,7 +2828,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div> </div>
<div className="header-filter-values"> <div className="header-filter-values">
{(distinctValuesByColumn[column.key] ?? []) {(distinctValuesByColumn[column.key] ?? [])
.filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))) .filter((value) =>
(column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value
)
.toLocaleLowerCase("de-DE")
.includes(
filterValueSearch
.trim()
.toLocaleLowerCase("de-DE")
)
)
.map((value) => { .map((value) => {
const selected = filterDraftValues.includes(value); const selected = filterDraftValues.includes(value);
return ( return (
@ -2834,12 +2857,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
> >
<span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span> <span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span>
<span>{value}</span> <span>
{column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value}
</span>
</button> </button>
); );
})} })}
{(distinctValuesByColumn[column.key] ?? []).filter((value) => {(distinctValuesByColumn[column.key] ?? []).filter(
value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")) (value) =>
(column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value
)
.toLocaleLowerCase("de-DE")
.includes(
filterValueSearch
.trim()
.toLocaleLowerCase("de-DE")
)
).length === 0 ? ( ).length === 0 ? (
<div className="header-filter-empty">Keine passenden Werte gefunden.</div> <div className="header-filter-empty">Keine passenden Werte gefunden.</div>
) : null} ) : null}
@ -3375,9 +3412,52 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
}} }}
> >
{isEditing ? ( {isEditing && column.key === "phaseType" ? (
<select
ref={(element) => {
inputRef.current = element;
}}
value={editingCell.draft}
aria-label="Phasenart auswählen"
onChange={(event) =>
void commitEdit("stay", event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commitEdit("stay");
} else if (event.key === "Escape") {
event.preventDefault();
cancelEdit();
} else if (event.key === "Tab") {
event.preventDefault();
void commitEdit(
event.shiftKey ? "prev" : "next"
);
}
}}
onBlur={() => {
requestAnimationFrame(() => {
const active =
document.activeElement as HTMLElement | null;
if (
!active ||
!containerRef.current?.contains(active)
) {
setEditingCell(null);
focusGridWithoutScroll();
}
});
}}
>
<option value="single_phase">1-phasig</option>
<option value="three_phase">3-phasig</option>
</select>
) : isEditing ? (
<input <input
ref={inputRef} ref={(element) => {
inputRef.current = element;
}}
value={editingCell.draft} value={editingCell.draft}
aria-invalid={hasDraftIdentifierConflict} aria-invalid={hasDraftIdentifierConflict}
title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined} title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined}

View file

@ -6,16 +6,6 @@ import { getCircuitTree } from "../utils/api";
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types"; import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
import { formatValue } from "../utils/circuit-grid-model"; import { formatValue } from "../utils/circuit-grid-model";
function formatPhaseType(value: string | undefined) {
if (value === "three_phase") {
return "Dreiphasig";
}
if (value === "single_phase") {
return "Einphasig";
}
return value ?? "-";
}
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) { function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
if (circuit.displayName?.trim()) { if (circuit.displayName?.trim()) {
return circuit.displayName; return circuit.displayName;
@ -140,7 +130,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
<tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}> <tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}>
<td>{circuit.equipmentIdentifier}</td> <td>{circuit.equipmentIdentifier}</td>
<td>{row.displayName || row.name}</td> <td>{row.displayName || row.name}</td>
<td>{formatPhaseType(row.phaseType)}</td> <td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
<td>{row.connectionKind ?? "-"}</td> <td>{row.connectionKind ?? "-"}</td>
<td>{row.costGroup ?? "-"}</td> <td>{row.costGroup ?? "-"}</td>
<td>{row.category ?? "-"}</td> <td>{row.category ?? "-"}</td>
@ -190,7 +180,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
<tr key={row.id} className="device-row"> <tr key={row.id} className="device-row">
<td className="text-muted"> </td> <td className="text-muted"> </td>
<td className="indented-cell">{row.displayName || row.name}</td> <td className="indented-cell">{row.displayName || row.name}</td>
<td>{formatPhaseType(row.phaseType)}</td> <td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
<td>{row.connectionKind ?? "-"}</td> <td>{row.connectionKind ?? "-"}</td>
<td>{row.costGroup ?? "-"}</td> <td>{row.costGroup ?? "-"}</td>
<td>{row.category ?? "-"}</td> <td>{row.category ?? "-"}</td>

View file

@ -187,7 +187,7 @@ export interface CreateGlobalDeviceInput {
quantity: number; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
phaseCount?: 1 | 3; phaseCount: 1 | 3;
powerFactor?: number; powerFactor?: number;
note?: string; note?: string;
} }

View file

@ -214,9 +214,22 @@ export function formatValue(value: GridValue, key?: CellKey): string {
if (typeof value === "number") { if (typeof value === "number") {
return formatNumber(value, getMaximumFractionDigits(key)); return formatNumber(value, getMaximumFractionDigits(key));
} }
if (key === "phaseType") {
return formatPhaseTypeLabel(String(value));
}
return String(value); return String(value);
} }
export function formatPhaseTypeLabel(value: string): string {
if (value === "single_phase") {
return "1-phasig";
}
if (value === "three_phase") {
return "3-phasig";
}
return value;
}
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined { export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (trimmed === "") { if (trimmed === "") {

View file

@ -7,7 +7,7 @@ export const createGlobalDeviceSchema = z.object({
quantity: z.number().min(0), quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0), installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1), demandFactor: z.number().min(0).max(1),
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(), phaseCount: z.union([z.literal(1), z.literal(3)]),
powerFactor: z.number().min(0).max(1).optional(), powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(), note: z.string().optional(),
}).strict(); }).strict();

View file

@ -82,6 +82,8 @@ describe("circuit grid model", () => {
assert.equal(formatValue(0.87654, "simultaneityFactor"), "0,88"); assert.equal(formatValue(0.87654, "simultaneityFactor"), "0,88");
assert.equal(formatValue(230.4, "voltage"), "230"); assert.equal(formatValue(230.4, "voltage"), "230");
assert.equal(formatValue(16, "protectionRatedCurrent"), "16"); assert.equal(formatValue(16, "protectionRatedCurrent"), "16");
assert.equal(formatValue("single_phase", "phaseType"), "1-phasig");
assert.equal(formatValue("three_phase", "phaseType"), "3-phasig");
}); });
it("uses circuit display values for block sorting and falls back to the first device", () => { it("uses circuit display values for block sorting and falls back to the first device", () => {

View file

@ -7,6 +7,7 @@ import {
import { import {
resolveCircuitPhaseType, resolveCircuitPhaseType,
resolveProjectVoltage, resolveProjectVoltage,
normalizeKnownElectricalPhaseType,
} from "../src/domain/services/project-voltage.service.js"; } from "../src/domain/services/project-voltage.service.js";
describe("circuit power calculation", () => { describe("circuit power calculation", () => {
@ -43,5 +44,17 @@ describe("project voltage derivation", () => {
assert.equal(resolveCircuitPhaseType("unassigned", []), "single_phase"); assert.equal(resolveCircuitPhaseType("unassigned", []), "single_phase");
assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase"); assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase");
}); });
it("normalizes known legacy phase labels without guessing unknown values", () => {
assert.equal(normalizeKnownElectricalPhaseType("1phasig"), "single_phase");
assert.equal(normalizeKnownElectricalPhaseType("1-phasig"), "single_phase");
assert.equal(normalizeKnownElectricalPhaseType("3phase"), "three_phase");
assert.equal(normalizeKnownElectricalPhaseType("3-phasig"), "three_phase");
assert.equal(normalizeKnownElectricalPhaseType(null), null);
assert.throws(
() => normalizeKnownElectricalPhaseType("zweiphasig"),
/Unknown electrical phase type/
);
});
}); });