diff --git a/src/domain/services/project-device-placement.service.ts b/src/domain/services/project-device-placement.service.ts index 3bf2123..ba47974 100644 --- a/src/domain/services/project-device-placement.service.ts +++ b/src/domain/services/project-device-placement.service.ts @@ -1,6 +1,7 @@ +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; + export interface ProjectDevicePlacementSource { - category?: string | null; - phaseType: "single_phase" | "three_phase"; + category: CircuitGroupCategory; } export interface ProjectDevicePlacementSection { @@ -14,11 +15,13 @@ export type DefaultCircuitSectionKey = "lighting" | "single_phase" | "three_phas export function inferProjectDeviceSectionKey( device: ProjectDevicePlacementSource ): DefaultCircuitSectionKey { - const category = (device.category ?? "").trim().toLowerCase(); - if (category.includes("light") || category.includes("beleuchtung")) { - return "lighting"; - } - return device.phaseType; + return device.category; +} + +export function resolveProjectDevicePhaseType( + category: CircuitGroupCategory +): "single_phase" | "three_phase" { + return category === "three_phase" ? "three_phase" : "single_phase"; } export function isProjectDevicePlacementValid( diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 3ecaf16..b63cd8e 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -3258,7 +3258,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str > {device.displayName || device.name} Name: {device.name} - Phasenart: {formatPhaseTypeLabel(device.phaseType)} Anzahl: {formatValue(device.quantity, "quantity")} Leistung/Gerät: {formatValue(device.powerPerUnit, "powerPerUnit")} kW @@ -3271,7 +3270,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str Gesamtleistung: {formatValue(device.totalPower, "rowTotalPower")} kW Kostengruppe: {device.costGroup || "-"} - Kategorie: {device.category || "-"} + Kategorie: {circuitGroupCategoryLabels[device.category]} ))} {searchableProjectDevices.length === 0 ?

Keine passenden Projektgeräte gefunden.

: null} diff --git a/src/frontend/components/project-device-modal.tsx b/src/frontend/components/project-device-modal.tsx index 907fffe..55c9d25 100644 --- a/src/frontend/components/project-device-modal.tsx +++ b/src/frontend/components/project-device-modal.tsx @@ -7,6 +7,11 @@ import type { ProjectDeviceDto, } from "../types"; import { FormModal } from "./form-modal"; +import { + circuitGroupCategories, + circuitGroupCategoryLabels, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group"; interface ProjectDeviceModalProps { globalDevices: GlobalDeviceDto[]; @@ -37,14 +42,12 @@ export function ProjectDeviceModal({ await onSave({ name: values.name.trim(), displayName: values.displayName.trim() || values.name.trim(), - phaseType: - values.phaseType === "three_phase" ? "three_phase" : "single_phase", connectionKind: optionalString(values.connectionKind), costGroup: optionalString(values.costGroup), - category: optionalString(values.category), - quantity: Number(values.quantity), - powerPerUnit: Number(values.powerPerUnit), - simultaneityFactor: Number(values.simultaneityFactor), + category: values.category as CircuitGroupCategory, + quantity: parseDecimal(values.quantity), + powerPerUnit: parseDecimal(values.powerPerUnit), + simultaneityFactor: parseDecimal(values.simultaneityFactor), cosPhi: optionalNumber(values.cosPhi), remark: optionalString(values.remark), }); @@ -52,10 +55,10 @@ export function ProjectDeviceModal({ const isValid = values.name.trim().length > 0 && - Number(values.quantity) >= 0 && - Number(values.powerPerUnit) >= 0 && - Number(values.simultaneityFactor) >= 0 && - Number(values.simultaneityFactor) <= 1; + isNumberInRange(values.quantity, 0) && + isNumberInRange(values.powerPerUnit, 0) && + isNumberInRange(values.simultaneityFactor, 0, 1) && + (!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1)); return ( update("displayName", value)} value={values.displayName} /> - update("category", value)} - value={values.category} - /> +
+ + +
update("costGroup", value)} value={values.costGroup} /> -
- - -
onChange(event.target.value)} step={step} - type="number" + inputMode="decimal" + type="text" value={value} /> @@ -255,10 +258,9 @@ function toFormValues(device?: ProjectDeviceDto) { return { name: device?.name ?? "", displayName: device?.displayName ?? "", - phaseType: device?.phaseType ?? "single_phase", connectionKind: device?.connectionKind ?? "", costGroup: device?.costGroup ?? "", - category: device?.category ?? "", + category: device?.category ?? "single_phase", quantity: String(device?.quantity ?? 1), powerPerUnit: String(device?.powerPerUnit ?? 0.1), simultaneityFactor: String(device?.simultaneityFactor ?? 1), @@ -272,5 +274,14 @@ function optionalString(value: string) { } function optionalNumber(value: string) { - return value.trim() ? Number(value) : undefined; + return value.trim() ? parseDecimal(value) : undefined; +} + +function parseDecimal(value: string) { + return Number(value.trim().replace(",", ".")); +} + +function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) { + const parsed = parseDecimal(value); + return Number.isFinite(parsed) && parsed >= min && parsed <= max; } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index ee58d0d..f7ab550 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -173,7 +173,7 @@ export interface ProjectDeviceDto { phaseType: "single_phase" | "three_phase"; connectionKind: string | null; costGroup: string | null; - category: string | null; + category: CircuitGroupCategory; quantity: number; powerPerUnit: number; simultaneityFactor: number; @@ -218,10 +218,9 @@ export interface CreateGlobalDeviceInput { export interface CreateProjectDeviceInput { name: string; displayName: string; - phaseType: "single_phase" | "three_phase"; connectionKind?: string; costGroup?: string; - category?: string; + category: CircuitGroupCategory; quantity: number; powerPerUnit: number; simultaneityFactor: number; diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index 2878f61..b10e718 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -23,6 +23,8 @@ import { import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; import { resolveProjectVoltage } from "../../domain/services/project-voltage.service.js"; +import { resolveProjectDevicePhaseType } from "../../domain/services/project-device-placement.service.js"; +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; export async function listProjectDevicesByProject(req: Request, res: Response) { const { projectId } = req.params; @@ -156,8 +158,10 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) { const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), { name: source.name, displayName: source.displayName, - phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase", - category: source.category ?? undefined, + category: inferImportedProjectDeviceCategory( + source.category, + source.phaseCount + ), quantity: source.quantity, powerPerUnit: source.installedPowerPerUnitKw, simultaneityFactor: source.demandFactor, @@ -205,22 +209,34 @@ function toProjectDeviceValues( threePhaseVoltageV: number; } ) { + const phaseType = resolveProjectDevicePhaseType(input.category); return { name: input.name, displayName: input.displayName, - phaseType: input.phaseType, + phaseType, connectionKind: input.connectionKind ?? null, costGroup: input.costGroup ?? null, - category: input.category ?? null, + category: input.category, quantity: input.quantity, powerPerUnit: input.powerPerUnit, simultaneityFactor: input.simultaneityFactor, cosPhi: input.cosPhi ?? null, remark: input.remark ?? null, - voltageV: resolveProjectVoltage(input.phaseType, project), + voltageV: resolveProjectVoltage(phaseType, project), }; } +function inferImportedProjectDeviceCategory( + category: string | null, + phaseCount: number | null +): CircuitGroupCategory { + const normalized = category?.trim().toLowerCase() ?? ""; + if (normalized.includes("light") || normalized.includes("beleuchtung")) { + return "lighting"; + } + return phaseCount === 3 ? "three_phase" : "single_phase"; +} + export async function getProjectDeviceSyncPreview(req: Request, res: Response) { const { projectId, projectDeviceId } = req.params; if (typeof projectId !== "string" || typeof projectDeviceId !== "string") { diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index f252502..da6bf65 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js"; import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; +import { circuitGroupCategories } from "../constants/circuit-group.js"; export const createProjectDeviceSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), - phaseType: z.enum(["single_phase", "three_phase"]), connectionKind: z.string().optional(), costGroup: z.string().optional(), - category: z.string().optional(), + category: z.enum(circuitGroupCategories), quantity: z.number().min(0), powerPerUnit: z.number().min(0), simultaneityFactor: z.number().min(0).max(1), diff --git a/tests/project-device-placement.service.test.ts b/tests/project-device-placement.service.test.ts index fef7ee6..6625ced 100644 --- a/tests/project-device-placement.service.test.ts +++ b/tests/project-device-placement.service.test.ts @@ -3,36 +3,27 @@ import { describe, it } from "node:test"; import { inferProjectDeviceSectionKey, isProjectDevicePlacementValid, + resolveProjectDevicePhaseType, } from "../src/domain/services/project-device-placement.service.js"; describe("project device placement", () => { - it("routes lighting categories to the lighting section before phase classification", () => { - assert.equal( - inferProjectDeviceSectionKey({ category: "Lighting", phaseType: "three_phase" }), - "lighting" - ); - assert.equal( - inferProjectDeviceSectionKey({ category: "Beleuchtung", phaseType: "single_phase" }), - "lighting" - ); - }); - - it("routes non-lighting devices by phase type", () => { - assert.equal( - inferProjectDeviceSectionKey({ category: "Socket", phaseType: "single_phase" }), - "single_phase" - ); - assert.equal( - inferProjectDeviceSectionKey({ category: "Motor", phaseType: "three_phase" }), - "three_phase" - ); + it("routes project devices by their required category", () => { + assert.equal(inferProjectDeviceSectionKey({ category: "lighting" }), "lighting"); + assert.equal(inferProjectDeviceSectionKey({ category: "single_phase" }), "single_phase"); + assert.equal(inferProjectDeviceSectionKey({ category: "three_phase" }), "three_phase"); }); it("accepts only the inferred default section", () => { - const device = { category: "Motor", phaseType: "three_phase" as const }; + const device = { category: "three_phase" as const }; assert.equal(isProjectDevicePlacementValid(device, { key: "three_phase" }), true); assert.equal(isProjectDevicePlacementValid(device, { key: "single_phase" }), false); assert.equal(isProjectDevicePlacementValid(device, { key: "unassigned" }), false); }); + + it("derives the electrical phase type from the category", () => { + assert.equal(resolveProjectDevicePhaseType("lighting"), "single_phase"); + assert.equal(resolveProjectDevicePhaseType("single_phase"), "single_phase"); + assert.equal(resolveProjectDevicePhaseType("three_phase"), "three_phase"); + }); }); diff --git a/tests/project-device-schema.test.ts b/tests/project-device-schema.test.ts index 7ee2f89..257170a 100644 --- a/tests/project-device-schema.test.ts +++ b/tests/project-device-schema.test.ts @@ -14,10 +14,9 @@ describe("project device circuit-first schema", () => { const result = createProjectDeviceSchema.safeParse({ name: "E-Line Pro", displayName: "Office lighting", - phaseType: "single_phase", connectionKind: "fixed", costGroup: "440", - category: "Lighting", + category: "lighting", quantity: 6, powerPerUnit: 0.04, simultaneityFactor: 0.8, @@ -28,7 +27,7 @@ describe("project device circuit-first schema", () => { assert.equal(result.success, true); }); - it("requires the circuit-first power and phase fields", () => { + it("requires the circuit-first power and category fields", () => { const result = createProjectDeviceSchema.safeParse({ name: "Test device", displayName: "Test device", @@ -45,7 +44,7 @@ describe("project device circuit-first schema", () => { const result = createProjectDeviceSchema.safeParse({ name: "Pumpe", displayName: "Pumpe", - phaseType: "three_phase", + category: "three_phase", quantity: 1, powerPerUnit: 2, simultaneityFactor: 1, @@ -59,7 +58,7 @@ describe("project device circuit-first schema", () => { const device = { name: "E-Line Pro", displayName: "Bürobeleuchtung", - phaseType: "single_phase" as const, + category: "lighting" as const, quantity: 6, powerPerUnit: 0.04, simultaneityFactor: 0.8, diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 300b972..bd6c615 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -283,7 +283,6 @@ describe("project device modal presentation", () => { "Kategorie", "Anschlussart", "Kostengruppe", - "Phasenart", "Anzahl", "Leistung je Stück [kW]", "Gleichzeitigkeitsfaktor", @@ -292,6 +291,10 @@ describe("project device modal presentation", () => { assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]"))); } assert.doesNotMatch(markup, /Spannung \[V\]/); + assert.doesNotMatch(markup, /Phasenart/); + assert.match(markup, /Beleuchtung/); + assert.match(markup, /1-phasig/); + assert.match(markup, /3-phasig/); }); });