Add estimate row project device handoff

This commit is contained in:
Julian Appel 2026-09-27 18:36:48 +02:00
parent 8b1923e8ec
commit a6500e2b26
9 changed files with 434 additions and 21 deletions

View file

@ -663,6 +663,13 @@ for the early-phase power balance by DIN 276 cost group (see
debounced (one Undo step per save), keeps invalid raw input local, and on a
409 reloads only if the balance itself changed; otherwise it re-saves with
the new revision. `request()` throws `ApiRequestError` (status, code).
- A single estimate row can be copied explicitly through the existing
ProjectDevice dialog. The dialog is only a prefilled proposal; creation
happens after confirmation through `project-device.insert`, with the current
project revision and normal Undo/Redo. The copy stores no link back to the
estimate row, never places a circuit row automatically, and does not change
estimate, snapshot or transfer schemas. Area rows require manual quantity
and power because W/m² remains outside the ProjectDevice model.
## Persistence and Migration Rules

View file

@ -9,7 +9,10 @@ Issue #7.
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
nichts in Stromkreislisten, Verteilungen oder Projektgeräte.
nichts automatisch in Stromkreislisten, Verteilungen oder Projektgeräte. Eine
ausdrückliche Einzelaktion kann die Werte einer Grobbilanz-Zeile als Vorschlag
in den bestehenden Projektgeräte-Dialog übernehmen; erst dessen Bestätigung
legt über `project-device.insert` ein unplatziertes Projektgerät an.
## Aufbau
@ -67,6 +70,35 @@ Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
## Übernahme als Projektgerät
Die Zeilenaktion „Als Projektgerät übernehmen“ ist eine einmalige, ausdrücklich
bestätigte Kopie. Vor dem Öffnen des Dialogs werden ausstehende Änderungen der
Grobbilanz gespeichert. Das Anlegen läuft anschließend mit der aktuellen
Projektrevision über den normalen Projektgeräte-Befehl und ist dadurch wie ein
manuell angelegtes Projektgerät rückgängig machbar.
- Name und Anzeigename kommen aus dem Zeilennamen; Kostengruppe, GZF, cos φ und
Anmerkung werden vorgeschlagen. `fixed` und `socket` erscheinen als
„Festanschluss“ und „Steckdose“.
- W werden für Stückzeilen in kW umgerechnet. Die Projektgeräte-Anzahl ist 1,
weil das Projektgerät als Vorlage für eine spätere Gerätezeile dient; die
ursprüngliche Stückzahl bleibt im Herkunftshinweis sichtbar.
- Die Kategorie ist mangels Phaseninformation in der Grobbilanz mit
„1-phasig“ vorbelegt und muss im Dialog fachlich geprüft werden.
- Flächenzeilen dürfen übernommen werden. Anzahl und Leistung je Stück bleiben
leer und müssen manuell angegeben werden, da das Projektgerät noch keine
Einheit W/m² kennt (siehe Issue #20).
- SV wird nur im Herkunftshinweis gezeigt. Die tatsächliche Versorgungsart
gehört zur Verteilung.
- Gleichnamige vorhandene Projektgeräte erzeugen eine Warnung, sperren das
Anlegen aber nicht.
Zwischen Grobbilanz-Zeile und Projektgerät wird keine Verbindung gespeichert.
Es gibt keine Stapelübernahme und keine automatische Platzierung in einer
Verteilung oder einem Stromkreis. Snapshot-, Transfer- und
Grobbilanz-Befehlsformate ändern sich dadurch nicht.
## Befehle, Undo/Redo
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit

View file

@ -2141,7 +2141,7 @@ button.structure-entry-main:focus-visible {
.estimate-col-result { width: 4.6rem; }
.estimate-col-sv { width: 3.2rem; }
.estimate-col-remark { width: 9rem; }
.estimate-col-actions { width: 2.4rem; }
.estimate-col-actions { width: 4.5rem; }
.estimate-cell-input,
.estimate-cell-select {
@ -2286,6 +2286,13 @@ button.structure-entry-main:focus-visible {
gap: 2px;
}
.estimate-row-actions {
display: flex;
justify-content: center;
gap: 2px;
white-space: nowrap;
}
.estimate-group-title-row .estimate-actions-cell {
width: auto;
white-space: nowrap;

View file

@ -14,11 +14,13 @@ import {
} from "lucide-react";
import {
ApiRequestError,
createProjectDevice,
getEstimateBalance,
getEstimateComparison,
getProject,
getProjectHistory,
isRevisionConflictError,
listProjectDevices,
redoProjectCommand,
undoProjectCommand,
updateEstimateBalance,
@ -26,11 +28,17 @@ import {
import type {
EstimateBalanceDto,
EstimateComparisonDto,
CreateProjectDeviceInput,
ProjectDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
} from "../../../../../frontend/types";
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
import type {
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../../../../shared/estimate-balance/estimate-balance";
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
import { ProjectDeviceModal } from "../../../../../frontend/components/project-device-modal";
import {
addEstimateGroup,
estimateBalanceFingerprint,
@ -41,7 +49,11 @@ import {
updateEstimateRow,
validateEstimateBalance,
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
import {
formatGermanNumber,
formatIsoDateGerman,
} from "../../../../../frontend/estimate-balance/estimate-balance-format";
import { createEstimateProjectDeviceInitialValues } from "../../../../../frontend/estimate-balance/estimate-project-device-prefill";
import {
EstimateBalanceSheet,
type EstimateBalanceUpdate,
@ -60,6 +72,10 @@ const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu ge
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
type GroupDialog = { group: EstimateGroupSnapshot | null };
type ProjectDeviceDialog = {
group: EstimateGroupSnapshot;
row: EstimateRowSnapshot;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
@ -94,6 +110,11 @@ export default function EstimateBalanceEditorPage() {
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
null
);
const [projectDeviceDialog, setProjectDeviceDialog] =
useState<ProjectDeviceDialog | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isPreparingProjectDevice, setIsPreparingProjectDevice] = useState(false);
const [isSavingProjectDevice, setIsSavingProjectDevice] = useState(false);
// Refs carry the latest values into timers, unload handlers and chained
// async saves without re-creating them.
@ -456,7 +477,77 @@ export default function EstimateBalanceEditorPage() {
setGzfTarget(null);
}
const isEditingDisabled = !draft || isHistoryBusy;
async function handleOpenProjectDevice(groupId: string, rowId: string) {
setError(null);
setNotice(null);
setIsPreparingProjectDevice(true);
try {
if (!(await saveNow())) {
setError(
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor ein Projektgerät angelegt werden kann."
);
return;
}
const current = draftRef.current;
const group = current?.groups.find((entry) => entry.id === groupId);
const row = group?.rows.find((entry) => entry.id === rowId);
if (!group || !row) {
setError("Die gewählte Grobbilanz-Zeile ist nicht mehr vorhanden.");
return;
}
const existingDevices = await listProjectDevices(projectId);
setProjectDevices(existingDevices);
setProjectDeviceDialog({ group, row });
} catch (err) {
setError(
errorMessage(err, "Der Projektgeräte-Dialog konnte nicht vorbereitet werden.")
);
} finally {
setIsPreparingProjectDevice(false);
}
}
async function handleCreateProjectDevice(input: CreateProjectDeviceInput) {
const revision = revisionRef.current;
if (!projectDeviceDialog || revision === null) return;
setError(null);
setIsSavingProjectDevice(true);
try {
if (!(await saveNow())) {
setError(
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor das Projektgerät angelegt werden kann."
);
return;
}
const currentRevision = revisionRef.current;
if (currentRevision === null) return;
const result = await createProjectDevice(projectId, input, currentRevision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
setProjectDevices((current) => [...current, result.projectDevice]);
setProjectDeviceDialog(null);
setNotice(`Projektgerät „${result.projectDevice.displayName}“ wurde angelegt.`);
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setError(
"Der Projektstand hat sich zwischenzeitlich geändert. Der aktuelle Stand wurde geladen; bitte das Projektgerät erneut speichern."
);
} else {
setError(errorMessage(err, "Das Projektgerät konnte nicht angelegt werden."));
}
} finally {
setIsSavingProjectDevice(false);
}
}
const isEditingDisabled =
!draft || isHistoryBusy || isPreparingProjectDevice || isSavingProjectDevice;
const invalidCount = invalidFieldKeys.size;
const listHref = `/projects/${projectId}/grobbilanz`;
@ -623,6 +714,9 @@ export default function EstimateBalanceEditorPage() {
onChange={handleChange}
onEditGroup={(group) => setGroupDialog({ group })}
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
onTakeOverRow={(groupId, rowId) =>
void handleOpenProjectDevice(groupId, rowId)
}
onValidityChange={handleValidityChange}
threePhaseVoltageV={project.threePhaseVoltageV}
/>
@ -692,10 +786,67 @@ export default function EstimateBalanceEditorPage() {
{gzfTarget ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
{projectDeviceDialog ? (
<ProjectDeviceModal
context={
<EstimateProjectDeviceContext
balanceName={draft?.name ?? ""}
group={projectDeviceDialog.group}
row={projectDeviceDialog.row}
/>
}
existingDevices={projectDevices}
globalDevices={[]}
initialValues={createEstimateProjectDeviceInitialValues(
projectDeviceDialog.group,
projectDeviceDialog.row
)}
isSaving={isSavingProjectDevice}
onClose={() => setProjectDeviceDialog(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
) : null}
</main>
);
}
function EstimateProjectDeviceContext({
balanceName,
group,
row,
}: {
balanceName: string;
group: EstimateGroupSnapshot;
row: EstimateRowSnapshot;
}) {
const originalUnit = row.unit === "area" ? "m²" : "St.";
const powerUnit = row.unit === "area" ? "W/m²" : "W";
return (
<>
<div>
<strong>Herkunft:</strong> Stand „{balanceName}“, Kostengruppe{" "}
{group.costGroup || "ohne Nummer"}.
</div>
<div>
Originalwerte: {formatGermanNumber(row.quantity, 0, 3)} {originalUnit} ×{" "}
{formatGermanNumber(row.powerPerUnitW, 0, 3)} {powerUnit}, GZF{" "}
{formatGermanNumber(row.simultaneityFactor, 0, 3)}, cos φ{" "}
{formatGermanNumber(row.cosPhi, 0, 3)}.
</div>
<div className="mt-1">
{row.unit === "area"
? "Das Projektgerät dient als Vorlage. Für Flächenwerte sind Anzahl und Leistung je Stück manuell einzutragen."
: "Das Projektgerät ist eine Vorlage mit Anzahl 1."}{" "}
Kategorie bitte prüfen.
{row.safetySupply
? " SV ist nur ein Hinweis; die Versorgungsart wird an der Verteilung festgelegt."
: ""}
</div>
</>
);
}
function SaveStatusBadge({
error,
invalidCount,

View file

@ -1,6 +1,6 @@
"use client";
import React, { FormEvent, useState } from "react";
import React, { FormEvent, type ReactNode, useState } from "react";
import type {
CreateProjectDeviceInput,
GlobalDeviceDto,
@ -14,10 +14,20 @@ import {
} from "../../shared/constants/circuit-group";
import { GzfPickerModal } from "./gzf-picker-modal";
export type ProjectDeviceModalInitialValues = Omit<
Partial<CreateProjectDeviceInput>,
"quantity" | "powerPerUnit"
> & {
quantity?: number | null;
powerPerUnit?: number | null;
};
interface ProjectDeviceModalProps {
context?: ReactNode;
existingDevices?: ReadonlyArray<Pick<ProjectDeviceDto, "name" | "displayName">>;
globalDevices: GlobalDeviceDto[];
initialDevice?: ProjectDeviceDto;
initialValues?: Partial<CreateProjectDeviceInput>;
initialValues?: ProjectDeviceModalInitialValues;
isSaving: boolean;
onClose: () => void;
onImportGlobal: (globalDeviceId: string) => Promise<void>;
@ -25,6 +35,8 @@ interface ProjectDeviceModalProps {
}
export function ProjectDeviceModal({
context,
existingDevices = [],
globalDevices,
initialDevice,
initialValues,
@ -36,6 +48,11 @@ export function ProjectDeviceModal({
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
const hasDuplicateName = hasExistingProjectDeviceName(
values.name,
values.displayName,
existingDevices
);
function update(key: keyof typeof values, value: string) {
setValues((current) => ({ ...current, [key]: value }));
@ -79,6 +96,17 @@ export function ProjectDeviceModal({
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
>
{context ? (
<div className="alert alert-info small" role="note">
{context}
</div>
) : null}
{hasDuplicateName ? (
<div className="alert alert-warning small" role="status">
Ein Projektgerät mit gleichem internen Namen oder Anzeigenamen ist bereits vorhanden.
Das Anlegen bleibt möglich.
</div>
) : null}
{!initialDevice && globalDevices.length > 0 ? (
<div className="border rounded p-3 mb-4">
<label className="form-label" htmlFor="global-project-device">
@ -287,7 +315,7 @@ function NumberField({
function toFormValues(
device?: ProjectDeviceDto,
initialValues?: Partial<CreateProjectDeviceInput>
initialValues?: ProjectDeviceModalInitialValues
) {
return {
name: device?.name ?? initialValues?.name ?? "",
@ -295,14 +323,24 @@ function toFormValues(
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
category: device?.category ?? initialValues?.category ?? "single_phase",
quantity: String(device?.quantity ?? initialValues?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1),
quantity: toInitialNumber(device?.quantity, initialValues?.quantity, 1),
powerPerUnit: toInitialNumber(device?.powerPerUnit, initialValues?.powerPerUnit, 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
remark: device?.remark ?? initialValues?.remark ?? "",
};
}
function toInitialNumber(
deviceValue: number | undefined,
initialValue: number | null | undefined,
fallback: number
) {
if (deviceValue !== undefined) return String(deviceValue);
if (initialValue === null) return "";
return String(initialValue ?? fallback);
}
function optionalString(value: string) {
return value.trim() || undefined;
}
@ -315,7 +353,29 @@ function parseDecimal(value: string) {
return Number(value.trim().replace(",", "."));
}
function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) {
export function isProjectDeviceNumberInRange(
value: string,
min: number,
max = Number.POSITIVE_INFINITY
) {
if (!value.trim()) return false;
const parsed = parseDecimal(value);
return Number.isFinite(parsed) && parsed >= min && parsed <= max;
}
const isNumberInRange = isProjectDeviceNumberInRange;
export function hasExistingProjectDeviceName(
name: string,
displayName: string,
existingDevices: ReadonlyArray<Pick<ProjectDeviceDto, "name" | "displayName">>
) {
const candidates = new Set(
[name, displayName].map((value) => value.trim().toLocaleLowerCase("de-DE")).filter(Boolean)
);
return existingDevices.some((device) =>
[device.name, device.displayName].some((value) =>
candidates.has(value.trim().toLocaleLowerCase("de-DE"))
)
);
}

View file

@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, PackagePlus, Pencil, Plus, Trash2 } from "lucide-react";
import {
calculateEstimateBalance,
calculateEstimateRow,
@ -46,6 +46,7 @@ interface EstimateBalanceSheetProps {
onChange: EstimateBalanceUpdate;
onEditGroup: (group: EstimateGroupSnapshot) => void;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onTakeOverRow: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
threePhaseVoltageV: number;
}
@ -178,6 +179,7 @@ function EstimateRowLine({
group,
onChange,
onOpenGzfPicker,
onTakeOverRow,
onValidityChange,
row,
rowIndex,
@ -186,6 +188,7 @@ function EstimateRowLine({
group: EstimateGroupSnapshot;
onChange: EstimateBalanceUpdate;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onTakeOverRow: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
row: EstimateRowSnapshot;
rowIndex: number;
@ -348,6 +351,15 @@ function EstimateRowLine({
/>
</td>
<td className="estimate-actions-cell estimate-no-print">
<div className="estimate-row-actions">
<IconButton
disabled={disabled}
label={`Als Projektgerät übernehmen (${rowLabel})`}
onClick={() => onTakeOverRow(group.id, row.id)}
variant="primary"
>
<PackagePlus aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Zeile löschen (${rowLabel})`}
@ -356,6 +368,7 @@ function EstimateRowLine({
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</div>
</td>
</tr>
);
@ -373,6 +386,7 @@ export function EstimateBalanceSheet({
onChange,
onEditGroup,
onOpenGzfPicker,
onTakeOverRow,
onValidityChange,
threePhaseVoltageV,
}: EstimateBalanceSheetProps) {
@ -546,6 +560,7 @@ export function EstimateBalanceSheet({
key={row.id}
onChange={onChange}
onOpenGzfPicker={onOpenGzfPicker}
onTakeOverRow={onTakeOverRow}
onValidityChange={onValidityChange}
row={row}
rowIndex={rowIndex}

View file

@ -0,0 +1,51 @@
import type { CreateProjectDeviceInput } from "../types";
import type {
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
export type EstimateProjectDeviceInitialValues = Omit<
Partial<CreateProjectDeviceInput>,
"quantity" | "powerPerUnit"
> & {
quantity?: number | null;
powerPerUnit?: number | null;
};
const connectionLabels = {
fixed: "Festanschluss",
socket: "Steckdose",
} as const;
/**
* Builds the editable ProjectDevice proposal for one estimate row. Area rows
* deliberately leave quantity and power blank because W/m² has no equivalent
* in the current ProjectDevice model.
*/
export function createEstimateProjectDeviceInitialValues(
group: Pick<EstimateGroupSnapshot, "costGroup">,
row: Pick<
EstimateRowSnapshot,
| "name"
| "unit"
| "connection"
| "powerPerUnitW"
| "simultaneityFactor"
| "cosPhi"
| "remark"
>
): EstimateProjectDeviceInitialValues {
const name = row.name.trim();
return {
name,
displayName: name,
category: "single_phase",
connectionKind: row.connection ? connectionLabels[row.connection] : undefined,
costGroup: group.costGroup || undefined,
quantity: row.unit === "area" ? null : 1,
powerPerUnit: row.unit === "area" ? null : row.powerPerUnitW / 1000,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark ?? undefined,
};
}

View file

@ -28,6 +28,7 @@ import {
getTodayIsoDate,
parseGermanDecimal,
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
import { createEstimateProjectDeviceInitialValues } from "../src/frontend/estimate-balance/estimate-project-device-prefill.js";
import {
ApiRequestError,
createEstimateBalance,
@ -264,6 +265,55 @@ describe("estimate balance document editing", () => {
});
});
describe("estimate row to project device prefill", () => {
it("maps a piece row to one reusable project-device template", () => {
const balance = sampleBalance();
const group = balance.groups[0]!;
const row = {
...group.rows[0]!,
name: " Hebeanlage ",
quantity: 4,
connection: "fixed" as const,
powerPerUnitW: 1250,
simultaneityFactor: 0.8,
cosPhi: 0.9,
remark: "bauseits",
};
assert.deepEqual(createEstimateProjectDeviceInitialValues(group, row), {
name: "Hebeanlage",
displayName: "Hebeanlage",
category: "single_phase",
connectionKind: "Festanschluss",
costGroup: "410",
quantity: 1,
powerPerUnit: 1.25,
simultaneityFactor: 0.8,
cosPhi: 0.9,
remark: "bauseits",
});
});
it("leaves quantity and power blank for an area row", () => {
const balance = sampleBalance();
const group = balance.groups[1]!;
const row = {
...createEstimateRow(0, () => "area-row"),
name: "Bürofläche",
unit: "area" as const,
connection: "socket" as const,
quantity: 250,
powerPerUnitW: 15,
};
const initial = createEstimateProjectDeviceInitialValues(group, row);
assert.equal(initial.quantity, null);
assert.equal(initial.powerPerUnit, null);
assert.equal(initial.connectionKind, "Steckdose");
assert.equal(initial.costGroup, "440");
});
});
describe("estimate balance saving", () => {
it("builds the trimmed server document with null for empty optional texts", () => {
let balance = sampleBalance();

View file

@ -3,7 +3,11 @@ import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import {
hasExistingProjectDeviceName,
isProjectDeviceNumberInRange,
ProjectDeviceModal,
} from "../src/frontend/components/project-device-modal.js";
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
import type {
ProjectRevisionSummaryDto,
@ -324,6 +328,42 @@ describe("project device modal presentation", () => {
assert.match(markup, /1-phasig/);
assert.match(markup, /3-phasig/);
});
it("rejects empty required numbers while keeping zero valid", () => {
assert.equal(isProjectDeviceNumberInRange("", 0), false);
assert.equal(isProjectDeviceNumberInRange(" ", 0), false);
assert.equal(isProjectDeviceNumberInRange("0", 0), true);
assert.equal(isProjectDeviceNumberInRange("0,8", 0, 1), true);
});
it("renders blank area values as invalid and warns about duplicate names", () => {
const markup = renderToStaticMarkup(
createElement(ProjectDeviceModal, {
existingDevices: [{ name: "Bürofläche", displayName: "Bürofläche" }],
globalDevices: [],
initialValues: {
name: "Bürofläche",
displayName: "Bürofläche",
quantity: null,
powerPerUnit: null,
},
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
})
);
assert.match(markup, /gleichem internen Namen oder Anzeigenamen/);
assert.match(markup, /id="device-quantity"[^>]*value=""/);
assert.match(markup, /id="device-power"[^>]*value=""/);
assert.match(markup, /<button class="btn btn-primary" disabled="" type="submit">/);
assert.equal(
hasExistingProjectDeviceName(" bürofläche ", "", [
{ name: "BÜROFLÄCHE", displayName: "Fläche" },
]),
true
);
});
});
describe("project version history API", () => {