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