Compare commits

..

11 commits

Author SHA1 Message Date
204499d7e4 Add Revit Dynamo diagnostic scripts 2026-08-23 23:13:35 +02:00
906aa751c7 Run the Docker stack on built output instead of dev servers
compose.yaml overrode the image's own entrypoint to start "tsx watch" and
"next dev" with CHOKIDAR_USEPOLLING and WATCHPACK_POLLING enabled. The
built dist/ and .next from the image were never used, and the polling
watchers kept both containers busy around the clock whether or not
anyone used the application.

Split the two use cases: compose.yaml now runs node dist/server/index.js
and next start with no source mounts and no watchers, and the watching
setup moves to compose.dev.yaml.

next build writes the rewrite destinations from next.config.mjs into
.next/routes-manifest.json, so next start cannot pick up API_INTERNAL_URL
at runtime the way next dev does. Pass it as a build argument.

Give the web container its own probe. The healthcheck hit "/", which
redirects to "/projects", and fetch follows redirects, so every probe
rendered two pages every five seconds. "/health" could not serve as the
probe because next.config.mjs rewrites it to the API. Add "/web-health"
as a route handler, exclude it from the proxy matcher, and relax both
intervals to 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:25:06 +02:00
c6cdfc42d5 Keep manual quantity consistent when synchronizing device rows
Synchronizing a project device pushes its quantity onto every linked
device row, but manualQuantity is not part of the sync snapshot and was
left untouched. A device whose quantity is lower than the row's left the
row at manualQuantity > quantity, violating the snapshot invariant.

The violation surfaced far from its cause: the invariant is only checked
when a full state snapshot is read, and the automatic snapshot runs every
25 revisions. A project could therefore accumulate the broken row
silently and then reject every subsequent command, because the failing
validation rolls back the whole transaction including the revision bump.

Recompute manualQuantity as the synchronized quantity minus the total of
the linked external objects, matching the invariant that
assertCircuitDeviceRowQuantity enforces elsewhere, and reject a
synchronized quantity that falls below that total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
9504905c8f Upgrade project to Node 24
Node 22 leaves active LTS at the end of October. Move the Docker base
image and the documented local requirement to Node 24.

Pull @types/node from ^25 down to ^24 so the type definitions no longer
describe a newer runtime than the one in use. Types ahead of the runtime
let TypeScript accept APIs that are not there at execution time; the
opposite direction is harmless.

Add an engines field to record the supported version. No .nvmrc, since
the project is not used with a Node version manager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
a17e2e3f4b Merge remote-tracking branch 'origin/feature/amev-picker-and-collapsible-sidebar' 2026-08-06 22:01:54 +02:00
64ccd1f829 Add dark mode toggle in the sidebar
Adds a persistent dark/light theme toggle (localStorage +
data-bs-theme on <html>) so it applies globally, including the
circuit editor route which has no visible sidebar. An inline
beforeInteractive script sets the theme before hydration to avoid a
flash of the wrong theme.

Bootstrap components pick up data-bs-theme automatically; the custom
circuit-grid CSS previously used ~150 hardcoded hex colors, all
converted to semantic tokens in globals.css with light/dark values.

Verified with a headless-browser pass across the project list, project
detail page, circuit editor, project-device drawer and a settings
modal in both themes; no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:56:21 +02:00
a99980c47b Update docs for the code-review fixes
- circuit-list-editor-api.md: next-identifier moved under
  /projects/:projectId and now documents its 404 scoping behavior.
- current-architecture.md: clarify that the BMK trigger register's
  DB-level normalization is ASCII-only and describe the added
  application-level Unicode-aware uniqueness check that closes the
  umlaut gap; note the additive migration 0006 indexes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:33:28 +02:00
b45dc5002d Fix code-review findings across domain, persistence, server and frontend
Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:

- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
  "ä1") because the DB's normalized index only folds ASCII case. Added
  a shared Unicode-aware pre-check used by every circuit/component
  insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
  level (command model and snapshot/restore schema), unlike every
  sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
  ("1.500" parsed as 1.5); "." is now rejected outright with a clear
  message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
  re-entrancy guard, so a double click/drop could fire the same
  command twice and race a BMK collision or revision conflict. Added a
  synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
  in other projects (no ownership check, 400 instead of 404). Moved
  under /projects/:projectId and scoped it.

Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:31:16 +02:00
fa96be2d42 Fix crash handling and log noise in logging feature
Review of the logging work from the previous commit turned up a real
correctness issue: the uncaughtException/unhandledRejection handlers
logged the error but let the process keep running, which silently
disabled Node's default crash-on-fatal-error behavior and could leave
a zombie process serving broken requests instead of restarting. Both
processes now log and then exit(1); restart: unless-stopped is added
to both services so Docker actually brings them back up.

Also: harden the logger against JSON.stringify throwing on
non-serializable meta, log aborted (closed-before-finished) requests
in the API access log, and stop the Next.js proxy from logging the
Docker healthcheck's request to "/" every few seconds so real
navigation events aren't drowned out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 19:58:31 +02:00
ea3c02cd6b Add configurable structured logging
Adds a leveled JSON logger (error/warn/info/verbose/debug, controlled via
LOG_LEVEL) wired into the Express API (access log, error middleware,
crash handlers, memory heartbeat) and the Next.js server (page-request
proxy, instrumentation crash handlers, heartbeat). LOG_LEVEL is exposed
through compose.yaml, and both services now rotate their Docker logs
(json-file, 20m x 10 files) instead of growing unbounded. Intended to
capture long-running diagnostic data for the intermittent 502s seen on
the Docker host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 19:45:39 +02:00
root
f26c000007 Add local uris to nextjs config 2026-08-06 18:58:56 +02:00
78 changed files with 5410 additions and 723 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ dist/
data/*.db
data/backups/*.db
.codex/*.log
dynamo/output/

View file

@ -1,10 +1,17 @@
FROM node:22
FROM node:24
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# next build writes the rewrite destinations from next.config.mjs into
# .next/routes-manifest.json, so "next start" cannot pick up a different
# API URL later. The value has to be known here, not just at runtime.
ARG API_INTERNAL_URL=http://localhost:3000
ENV API_INTERNAL_URL=$API_INTERNAL_URL
RUN npm run build:api && npm run build:web
RUN mkdir -p data && chmod +x scripts/docker-start.sh

View file

@ -62,15 +62,29 @@ docker compose logs --follow
docker compose down
```
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
Produktionsdeployment. Details stehen in
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
[Deployment und Betrieb](docs/deployment.md).
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
Quellcode-Mounts und Watchern:
```powershell
docker compose -f compose.dev.yaml up --build --detach
docker compose -f compose.dev.yaml logs --follow
docker compose -f compose.dev.yaml down
```
Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und
macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn
niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen
Server.
## Direkte lokale Entwicklung
Voraussetzungen:
- Node.js 22
- Node.js 24
- npm
```powershell

87
compose.dev.yaml Normal file
View file

@ -0,0 +1,87 @@
# Development stack: source mounts, watching dev servers, hot reload.
# docker compose -f compose.dev.yaml up --build
#
# The polling watchers below are needed for bind mounts on Windows and
# macOS, where inotify events do not cross the VM boundary. They cost
# continuous CPU, which is why the production stack in compose.yaml does
# not run watchers at all.
name: leistungsbilanz-dev
x-logging: &logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
services:
api:
build:
context: .
command:
- sh
- -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
environment:
PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-debug}"
init: true
restart: unless-stopped
logging: *logging
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./scripts:/app/scripts
- ./data:/app/data
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
- ./tsconfig.json:/app/tsconfig.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
retries: 5
start_period: 20s
web:
build:
context: .
command:
- npm
- run
- dev:web
- --
- --hostname
- 0.0.0.0
environment:
API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-debug}"
init: true
restart: unless-stopped
logging: *logging
depends_on:
api:
condition: service_healthy
ports:
- "3001:3001"
volumes:
- ./src:/app/src
- ./next.config.mjs:/app/next.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
retries: 5
start_period: 20s

View file

@ -1,68 +1,73 @@
name: leistungsbilanz
x-build: &build
context: .
args:
# Baked into .next/routes-manifest.json by next build; see Dockerfile.
API_INTERNAL_URL: http://api:3000
x-logging: &logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
services:
api:
build:
context: .
build: *build
command:
- sh
- -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
environment:
NODE_ENV: production
PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging: *logging
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./scripts:/app/scripts
- ./data:/app/data
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
- ./tsconfig.json:/app/tsconfig.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s
interval: 30s
timeout: 3s
retries: 12
retries: 5
start_period: 20s
web:
build:
context: .
build: *build
command:
- npm
- run
- dev:web
- --
- --hostname
- 0.0.0.0
- node_modules/.bin/next
- start
- -p
- "3001"
environment:
NODE_ENV: production
API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging: *logging
depends_on:
api:
condition: service_healthy
ports:
- "3001:3001"
volumes:
- ./src:/app/src
- ./next.config.mjs:/app/next.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
retries: 12
retries: 5
start_period: 20s

View file

@ -359,8 +359,9 @@ Response sketch:
### Circuit Structure
- `GET /circuit-sections/:sectionId/next-identifier`
- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`)
- returns 404 if the section does not belong to the given project
Circuit and device-row field updates, standalone insertions/deletions, single
or bulk device-row moves, circuit reorders and explicit renumbering are

View file

@ -476,16 +476,28 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
und Verteilerkomponenten. Separate
1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren
flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
Ein triggergeführtes Register erzwingt eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites
eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht
als denselben Wert. Die gemeinsame Prüfung
`src/db/repositories/equipment-identifier-uniqueness.persistence.ts`
schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte
Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite
Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes
Unterbaumlöschen und Stromkreisschutz sind integriert.
Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und
`circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten
Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und
Stromkreisen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen

View file

@ -2,19 +2,27 @@
## Aktueller Status
Es gibt derzeit kein unterstütztes Produktionsdeployment.
Es gibt zwei Compose-Stacks.
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
öffentlich erreichbar gemacht werden.
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
für einen Server.
## Entwicklungs-Topologie
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
erreichbar gemacht werden.
## Topologie
| Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- |
| Next.js Web | 3001 | `GET /` | keine |
| Next.js Web | 3001 | `GET /web-health` | keine |
| Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
@ -24,11 +32,56 @@ Verwendete Umgebungsvariablen:
- `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
Dateibeobachtung in Docker
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` nur in
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
- `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`.
Beim API-Start laufen zuerst `npm run db:migrate` und
`npm run db:verify:circuit-schema`.
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
Entwicklungsstack über `npm run db:migrate` und
`npm run db:verify:circuit-schema`).
`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build`
schreibt die Rewrite-Ziele aus `next.config.mjs` fest in
`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als
Build-Argument an das Image durch, nicht nur als Laufzeit-Variable.
## Logging
Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach
stdout/stderr (`docker compose logs --follow`). Jede Zeile enthält
`timestamp`, `level`, `scope` und `message`. `compose.yaml` konfiguriert für
beide Dienste den `json-file`-Treiber mit Rotation (`max-size: 20m`,
`max-file: 10`, also bis zu 200 MB je Dienst); ohne diese Einstellung würde
Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter
`/var/lib/docker/containers/<container-id>/` schreiben. Die Logs überleben
einen Container-Neustart (`docker compose restart`), aber nicht das Entfernen
des Containers (`docker compose down` gefolgt von `up` erzeugt neue
Container und damit neue, leere Logdateien); für ein echtes Langzeitarchiv
über Rebuilds hinweg müssten die Zeilen zusätzlich in eine Datei im
gemounteten `./data`-Verzeichnis oder an ein externes Log-System geschrieben
werden. Die Express-API protokolliert
jede abgeschlossene Anfrage (Methode, Pfad, Status, Dauer; `/health` wird
nicht mitgeloggt) sowie unbehandelte Exceptions/Promise-Rejections. Das
Next.js-Frontend protokolliert Seitenanfragen (Navigation) über
`src/proxy.ts` und unbehandelte Fehler über `src/instrumentation.ts`.
Beide Prozesse schreiben zusätzlich alle fünf Minuten einen `verbose`-Heartbeat
mit Laufzeit und Speicherverbrauch nützlich, um Speicherlecks oder Hänger vor
einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche
`LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder
API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar.
Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before
response finished` (`warn`) geloggt und zeigt damit vom Client oder einem
vorgeschalteten Proxy abgebrochene Verbindungen.
Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet
den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in
einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
## Voraussetzungen für ein späteres Produktionssetup

View file

@ -0,0 +1,385 @@
"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node.
Optional Dynamo input:
IN[0]: output directory or complete .json file path
The script intentionally performs no Revit transaction and changes no model
data. OUT contains a compact summary plus the complete report.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402
from RevitServices.Persistence import DocumentManager # noqa: E402
CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId")
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {
"hasValue": False,
"raw": None,
"display": None,
}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
data_type = None
group_type = None
if definition is not None:
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def sorted_parameters(element):
parameters = []
try:
parameters = [describe_parameter(parameter) for parameter in element.Parameters]
except Exception:
return []
return sorted(
parameters,
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
),
)
def named_parameter_occurrences(element, parameter_name):
result = []
try:
parameters = element.GetParameters(parameter_name)
if parameters is not None:
result = [describe_parameter(parameter) for parameter in parameters]
except Exception:
parameter = None
try:
parameter = element.LookupParameter(parameter_name)
except Exception:
pass
if parameter is not None:
result = [describe_parameter(parameter)]
return result
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def get_cloud_identity(document):
result = {"isModelInCloud": False}
try:
result["isModelInCloud"] = bool(document.IsModelInCloud)
except Exception:
return result
if not result["isModelInCloud"]:
return result
try:
model_path = document.GetCloudModelPath()
result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
for property_name, output_name in (
("GetProjectGUID", "projectGuid"),
("GetModelGUID", "modelGuid"),
):
try:
result[output_name] = str(getattr(model_path, property_name)())
except Exception:
pass
except Exception as error:
result["readError"] = safe_text(error)
return result
def get_worksharing_identity(document):
result = {"isWorkshared": False}
try:
result["isWorkshared"] = bool(document.IsWorkshared)
except Exception:
return result
if not result["isWorkshared"]:
return result
try:
model_path = document.GetWorksharingCentralModelPath()
result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
except Exception as error:
result["readError"] = safe_text(error)
return result
def resolve_output_path(configured_path, report_name):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report():
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
project_information = document.ProjectInformation
if project_information is None:
raise RuntimeError("The active document has no Project Information element.")
application = document.Application
checked_parameters = {
name: named_parameter_occurrences(project_information, name)
for name in CHECKED_PARAMETER_NAMES
}
warnings = []
for name in CHECKED_PARAMETER_NAMES:
occurrences = checked_parameters[name]
populated = [
parameter
for parameter in occurrences
if parameter.get("value", {}).get("raw") not in (None, "")
]
if not occurrences:
warnings.append(name + " is not bound to Project Information.")
elif not populated:
warnings.append(name + " exists but has no value on Project Information.")
elif len(occurrences) > 1:
warnings.append(name + " occurs more than once; use a shared-parameter GUID later.")
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"isFamilyDocument": bool(document.IsFamilyDocument),
"cloud": get_cloud_identity(document),
"worksharing": get_worksharing_identity(document),
},
"modelIdentityCandidates": {
"projectInformationUniqueId": safe_text(project_information.UniqueId),
"projectInformationElementId": element_id_text(project_information.Id),
"checkedProjectParameters": checked_parameters,
},
"projectInformationParameters": sorted_parameters(project_information),
"warnings": warnings,
}
try:
report = build_report()
output_path = resolve_output_path(get_input(0), "model-identity")
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"projectInformationUniqueId": report["modelIdentityCandidates"][
"projectInformationUniqueId"
],
"warnings": report["warnings"],
"report": report,
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

View file

@ -0,0 +1,517 @@
"""Export all Electrical Fixtures instance/type parameters from Revit 2026.
Optional Dynamo inputs:
IN[0]: output directory or complete .json file path
IN[1]: include empty parameters (default True)
IN[2]: maximum aggregated sample values (default 5)
The script is read-only and performs no Revit transaction.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ( # noqa: E402
BuiltInCategory,
FilteredElementCollector,
ModelPathUtils,
StorageType,
)
from RevitServices.Persistence import DocumentManager # noqa: E402
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {"hasValue": False, "raw": None, "display": None}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter, scope):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
data_type = None
group_type = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"scope": scope,
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def has_meaningful_value(parameter_description):
value = parameter_description.get("value", {})
return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get(
"display"
) not in (None, "")
def read_parameters(element, scope, include_empty):
result = []
try:
for parameter in element.Parameters:
description = describe_parameter(parameter, scope)
if include_empty or has_meaningful_value(description):
result.append(description)
except Exception as error:
return [], [safe_text(error)]
result.sort(
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
)
)
return result, []
def read_space(element, document):
try:
space = element.Space
except Exception as error:
return None, safe_text(error)
if space is None:
return None, None
level_name = None
try:
level = document.GetElement(space.LevelId)
level_name = None if level is None else safe_text(level.Name)
except Exception:
pass
return {
"uniqueId": safe_text(space.UniqueId),
"elementId": element_id_text(space.Id),
"number": safe_text(getattr(space, "Number", None)),
"name": safe_text(getattr(space, "Name", None)),
"levelName": level_name,
}, None
def read_family_identity(element, document):
symbol = None
try:
symbol = element.Symbol
except Exception:
try:
symbol = document.GetElement(element.GetTypeId())
except Exception:
pass
family_name = None
type_name = None
type_unique_id = None
type_element_id = None
if symbol is not None:
try:
family_name = safe_text(symbol.Family.Name)
except Exception:
family_name = safe_text(getattr(symbol, "FamilyName", None))
type_name = safe_text(getattr(symbol, "Name", None))
type_unique_id = safe_text(getattr(symbol, "UniqueId", None))
type_element_id = element_id_text(getattr(symbol, "Id", None))
return {
"familyName": family_name,
"typeName": type_name,
"typeUniqueId": type_unique_id,
"typeElementId": type_element_id,
}, symbol
def parameter_inventory_key(parameter):
stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or ""
return "|".join(
(
parameter.get("scope") or "",
stable_id,
parameter.get("name") or "",
parameter.get("dataTypeId") or "",
)
)
def sample_value_key(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def add_to_inventory(inventory, parameter, max_samples):
key = parameter_inventory_key(parameter)
entry = inventory.get(key)
if entry is None:
entry = {
"scope": parameter.get("scope"),
"name": parameter.get("name"),
"parameterId": parameter.get("parameterId"),
"isShared": parameter.get("isShared"),
"sharedGuid": parameter.get("sharedGuid"),
"storageType": parameter.get("storageType"),
"dataTypeId": parameter.get("dataTypeId"),
"groupTypeId": parameter.get("groupTypeId"),
"unitTypeId": parameter.get("unitTypeId"),
"occurrenceCount": 0,
"populatedCount": 0,
"sampleValues": [],
"_sampleKeys": set(),
}
inventory[key] = entry
entry["occurrenceCount"] += 1
if has_meaningful_value(parameter):
entry["populatedCount"] += 1
value = parameter.get("value")
value_key = sample_value_key(value)
if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]:
entry["_sampleKeys"].add(value_key)
entry["sampleValues"].append(value)
def finalize_inventory(inventory):
result = []
for entry in inventory.values():
clean_entry = dict(entry)
clean_entry.pop("_sampleKeys", None)
result.append(clean_entry)
return sorted(
result,
key=lambda entry: (
entry.get("scope") or "",
(entry.get("name") or "").casefold(),
entry.get("sharedGuid") or entry.get("parameterId") or "",
),
)
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def resolve_output_path(configured_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report(include_empty, max_samples):
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
if document.IsFamilyDocument:
raise RuntimeError("Open a Revit project document, not a family document.")
application = document.Application
collector = (
FilteredElementCollector(document)
.OfCategory(BuiltInCategory.OST_ElectricalFixtures)
.WhereElementIsNotElementType()
)
source_elements = list(collector)
source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "")
elements = []
types_by_unique_id = {}
inventory = {}
errors = []
elements_without_space = 0
for element in source_elements:
element_errors = []
try:
family_identity, symbol = read_family_identity(element, document)
instance_parameters, parameter_errors = read_parameters(
element, "instance", include_empty
)
element_errors.extend(parameter_errors)
for parameter in instance_parameters:
add_to_inventory(inventory, parameter, max_samples)
type_unique_id = family_identity.get("typeUniqueId")
if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id:
type_parameters, type_errors = read_parameters(symbol, "type", include_empty)
element_errors.extend(type_errors)
for parameter in type_parameters:
add_to_inventory(inventory, parameter, max_samples)
types_by_unique_id[type_unique_id] = {
**family_identity,
"parameters": type_parameters,
}
space, space_error = read_space(element, document)
if space_error:
element_errors.append("MEP Space: " + space_error)
if space is None:
elements_without_space += 1
elements.append(
{
"uniqueId": safe_text(element.UniqueId),
"elementId": element_id_text(element.Id),
"categoryName": safe_text(
None if element.Category is None else element.Category.Name
),
"family": family_identity,
"space": space,
"instanceParameters": instance_parameters,
"warnings": element_errors,
}
)
except Exception as error:
errors.append(
{
"uniqueId": safe_text(getattr(element, "UniqueId", None)),
"elementId": element_id_text(getattr(element, "Id", None)),
"error": safe_text(error),
}
)
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"complete": len(errors) == 0,
"scope": {
"builtInCategory": "OST_ElectricalFixtures",
"wholeDocument": True,
"elementTypesExcluded": True,
"includeEmptyParameters": include_empty,
},
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId),
},
"summary": {
"elementCount": len(source_elements),
"exportedElementCount": len(elements),
"typeCount": len(types_by_unique_id),
"parameterDefinitionCount": len(inventory),
"elementsWithoutMepSpace": elements_without_space,
"elementErrorCount": len(errors),
},
"parameterInventory": finalize_inventory(inventory),
"types": sorted(
types_by_unique_id.values(),
key=lambda entry: (
(entry.get("familyName") or "").casefold(),
(entry.get("typeName") or "").casefold(),
entry.get("typeUniqueId") or "",
),
),
"elements": elements,
"errors": errors,
}
try:
include_empty_input = get_input(1, True)
include_empty = bool(include_empty_input)
try:
max_samples = int(get_input(2, 5))
except Exception:
max_samples = 5
max_samples = max(0, min(max_samples, 50))
report = build_report(include_empty, max_samples)
output_path = resolve_output_path(get_input(0))
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"complete": report["complete"],
"summary": report["summary"],
"errors": report["errors"],
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

69
dynamo/README.md Normal file
View file

@ -0,0 +1,69 @@
# Revit 2026 / Dynamo diagnostics
This directory contains self-contained Python scripts for a Dynamo **Python
Script** node. They use only Dynamo's built-in Revit integration, the Revit API
and the Python standard library. No Dynamo package is required.
The scripts are read-only. They do not start a Revit transaction and do not
change the open model.
## Python engine
Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with
Revit; optional PythonNet3 packages are not required by these diagnostics.
## 01 - Check model identity
File: `01_check_model_identity.py`
The script reports:
- Revit, Dynamo and Python versions;
- `ProjectInformation.UniqueId` as a native model-identity candidate;
- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project
Information;
- all Project Information parameters;
- optional cloud/worksharing identity information when the API exposes it.
Input `IN[0]` is optional. It may be either an output directory or a complete
`.json` file path. With no input, the report is written below the current
Windows temporary directory in `leistungsbilanz-dynamo`.
## 02 - Inventory Electrical Fixtures parameters
File: `02_export_electrical_fixture_parameter_inventory.py`
The script reads every instance of
`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It
exports:
- element, family, type and MEP Space identities;
- every instance parameter and value;
- every unique family-type parameter and value;
- an aggregated parameter inventory with occurrence counts and sample values;
- per-element warnings instead of aborting at the first unreadable element.
Inputs:
- `IN[0]` (optional): output directory or complete `.json` path;
- `IN[1]` (optional): include empty parameters, default `true`;
- `IN[2]` (optional): maximum sample values per aggregated parameter, default
`5`.
The default output location is again the Windows temporary directory. The
generated report can contain model paths and project-specific parameter values;
review it before sharing or committing it.
## Running a script
1. Open the target model in Revit 2026.
2. Open Dynamo from **Manage > Visual Programming > Dynamo**.
3. Create a graph and add a **Python Script** node.
4. Select the `CPython3` engine for the node.
5. Copy the complete content of the desired `.py` file into the node.
6. Optionally connect a String node containing the output path to `IN[0]`.
7. Run the graph and inspect `OUT` for status, counts and the generated path.
For the first test, run `01_check_model_identity.py` in the Revit main model.
Then run the parameter inventory. Keep both generated JSON files so their
structure can be checked before the production snapshot DTO is finalized.

View file

@ -2,6 +2,12 @@
const apiInternalUrl = (process.env.API_INTERNAL_URL || "http://localhost:3000").replace(/\/$/, "");
const nextConfig = {
allowedDevOrigins: [
"192.168.3.13",
"docker01.int.jappel.io",
"lb.jappel.io"
],
typescript: {
tsconfigPath: "./tsconfig.next.json",
},
@ -20,3 +26,4 @@ const nextConfig = {
};
export default nextConfig;

23
package-lock.json generated
View file

@ -22,12 +22,15 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
},
"engines": {
"node": "24.x"
}
},
"node_modules/@drizzle-team/brocli": {
@ -1514,12 +1517,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qs": {
@ -3688,10 +3692,11 @@
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"devOptional": true
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",

View file

@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
"main": "dist/server/index.js",
"engines": {
"node": "24.x"
},
"scripts": {
"dev": "npm run dev:api",
"dev:api": "tsx watch src/server/index.ts",
@ -10,6 +13,9 @@
"docker:up": "docker compose up --build --detach",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs --follow",
"docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach",
"docker:dev:down": "docker compose -f compose.dev.yaml down",
"docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow",
"build": "npm run build:api",
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
@ -40,7 +46,7 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10",

View file

@ -3,7 +3,13 @@
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
per Ladereihenfolge) statt Bootstrap zu ersetzen.
Dark mode: Bootstrap schaltet seine eigenen Komponenten (Formulare,
Modals, Cards, Alerts, ...) automatisch um, sobald `data-bs-theme="dark"`
auf <html> steht (gesetzt von ThemeToggle.tsx, persistiert in
localStorage). Der custom Stromkreis-Grid nutzt dafür dieselben
Tokens unten, per `:root[data-bs-theme="dark"]` überschrieben. */
:root {
--color-primary: #1c3f52; /* Petrol */
--color-primary-dark: #0f2733; /* Petrol Dark */
@ -33,6 +39,105 @@
--space-xl: 40px;
--radius: 12px;
/* ── Grid/Editor-Tokens (hell) ──────────────────────────────────────── */
--panel-bg: #ffffff;
--panel-bg-subtle: #f8fafc;
--panel-border: #d9dee8;
--panel-border-strong: #c4cddc;
--input-border: #9fb6e0;
--text-strong: #1f2937;
--text-muted: #4b5563;
--text-faint: #6b7280;
--text-subtle: #475569;
--accent-blue: #2563eb;
--accent-blue-soft-bg: #eff6ff;
--accent-blue-border: #bfdbfe;
--accent-blue-strong-border: #4c7dd9;
--accent-blue-drop-border: #2b6cb0;
--accent-blue-marker: #1d4ed8;
--surface-selected: #eaf1ff;
--surface-header-row: #e8eef8;
--surface-component-header: #e2e8f0;
--surface-component-group: #f8fafc;
--surface-component-footer: #f1f5f9;
--surface-hover: #f3f4f6;
--grid-warn: #d97706;
--grid-warn-bg: #fff7ed;
--grid-warn-strong: #9a3412;
--grid-danger: #c2410c;
--notice-info-bg: #ebf3ff;
--notice-info-border: #bad1f7;
--notice-error-bg: #fdecec;
--notice-error-border: #f5b5b5;
--notice-warning-bg: #fff7ed;
--notice-warning-border: #fdba74;
--notice-muted-bg: #f6f6f6;
--notice-muted-border: #e4e4e4;
--shadow-soft: rgba(15, 39, 51, 0.07);
--shadow-strong: rgba(15, 39, 51, 0.28);
--shadow-menu: rgba(0, 0, 0, 0.12);
--shadow-drawer: rgba(31, 41, 55, 0.22);
}
:root[data-bs-theme="dark"] {
--color-ink: #e6ecef;
--color-ink-soft: #9db0b8;
--color-bg: #141b20;
--color-surface: #0f1519;
--color-border: #2a3941;
--color-danger: #e2664f;
--color-danger-bg: #33201c;
--color-warn: #e0983f;
/* ── Grid/Editor-Tokens (dunkel) ────────────────────────────────────── */
--panel-bg: #182229;
--panel-bg-subtle: #1c2830;
--panel-border: #2c3c45;
--panel-border-strong: #374a54;
--input-border: #3c5568;
--text-strong: #e6ecef;
--text-muted: #aebdc4;
--text-faint: #8798a0;
--text-subtle: #9db0b8;
--accent-blue: #6fa2f7;
--accent-blue-soft-bg: #17253a;
--accent-blue-border: #2c4a72;
--accent-blue-strong-border: #6fa2f7;
--accent-blue-drop-border: #4f84d6;
--accent-blue-marker: #6fa2f7;
--surface-selected: #17253a;
--surface-header-row: #1a262f;
--surface-component-header: #202e37;
--surface-component-group: #1a262f;
--surface-component-footer: #182229;
--surface-hover: #1f2b33;
--grid-warn: #e0983f;
--grid-warn-bg: #2e2214;
--grid-warn-strong: #f0b880;
--grid-danger: #e2825f;
--notice-info-bg: #182839;
--notice-info-border: #2c4a72;
--notice-error-bg: #33201c;
--notice-error-border: #5c332c;
--notice-warning-bg: #2e2214;
--notice-warning-border: #6b4a1c;
--notice-muted-bg: #1b2226;
--notice-muted-border: #2a3338;
--shadow-soft: rgba(0, 0, 0, 0.35);
--shadow-strong: rgba(0, 0, 0, 0.55);
--shadow-menu: rgba(0, 0, 0, 0.45);
--shadow-drawer: rgba(0, 0, 0, 0.55);
}
html {
@ -109,6 +214,12 @@ a {
--bs-alert-border-color: #b6e2ce;
}
:root[data-bs-theme="dark"] .alert-success {
--bs-alert-color: #7fd6a7;
--bs-alert-bg: #16291f;
--bs-alert-border-color: #205a38;
}
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
.app-shell {
@ -165,6 +276,7 @@ a {
flex-direction: column;
gap: 2px;
padding: 12px;
flex: 1;
}
.sidebar-section-label {
@ -210,6 +322,32 @@ a {
justify-content: center;
}
.sidebar-footer {
padding: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.sidebar-theme-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
color: #c7d6e0;
font-size: 0.92rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
width: 100%;
text-align: left;
cursor: pointer;
}
.sidebar-theme-toggle:hover {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
.app-shell-content {
width: 100%;
min-width: 0;
@ -230,6 +368,10 @@ a {
color: var(--color-primary);
}
:root[data-bs-theme="dark"] .page-header h1 {
color: var(--color-accent-pale);
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.12em;
@ -262,7 +404,7 @@ a {
a.kpi:hover {
color: inherit;
text-decoration: none;
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
box-shadow: 0 3px 8px var(--shadow-soft), 0 16px 32px -14px var(--shadow-strong);
transform: translateY(-1px);
}
@ -285,12 +427,16 @@ a.kpi:hover {
color: var(--color-primary);
}
:root[data-bs-theme="dark"] .kpi-value {
color: var(--color-accent-pale);
}
.card {
border-radius: var(--radius);
border-top: 3px solid var(--color-primary);
box-shadow:
0 1px 2px rgba(15, 39, 51, 0.05),
0 10px 24px -14px rgba(15, 39, 51, 0.22);
0 1px 2px var(--shadow-soft),
0 10px 24px -14px var(--shadow-strong);
}
.card-header {
@ -300,6 +446,10 @@ a.kpi:hover {
border-bottom: 1px solid var(--color-border);
}
:root[data-bs-theme="dark"] .card-header {
color: var(--color-accent-pale);
}
.table td input.form-control-sm,
.table td select.form-select-sm {
min-width: 8rem;
@ -320,13 +470,14 @@ a.kpi:hover {
top: 0;
z-index: 12;
padding: 0.35rem 0;
background: #fff;
box-shadow: 0 1px 0 rgba(196, 205, 220, 0.8);
background: var(--panel-bg);
box-shadow: 0 1px 0 var(--panel-border-strong);
}
.editor-toolbar button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
padding: 0.28rem 0.6rem;
border-radius: 4px;
font-size: 0.82rem;
@ -337,8 +488,8 @@ a.kpi:hover {
}
.editor-toolbar .project-device-drawer-toggle {
border-color: #2563eb;
background: #2563eb;
border-color: var(--accent-blue);
background: var(--accent-blue);
color: #fff;
font-weight: 600;
}
@ -349,10 +500,10 @@ a.kpi:hover {
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem;
border: 1px solid #bfdbfe;
border: 1px solid var(--accent-blue-border);
border-radius: 5px;
background: #eff6ff;
color: #1e3a5f;
background: var(--accent-blue-soft-bg);
color: var(--text-subtle);
font-size: 0.78rem;
}
@ -362,10 +513,10 @@ a.kpi:hover {
.active-view-chip,
.active-view-reset {
border: 1px solid #93b4df;
border: 1px solid var(--accent-blue-border);
border-radius: 999px;
background: #fff;
color: #1e3a5f;
background: var(--panel-bg);
color: var(--text-subtle);
padding: 0.18rem 0.48rem;
font-size: 0.76rem;
cursor: pointer;
@ -373,7 +524,7 @@ a.kpi:hover {
.active-view-chip:hover,
.active-view-reset:hover {
border-color: #2563eb;
border-color: var(--accent-blue);
}
.active-view-reset {
@ -386,9 +537,9 @@ a.kpi:hover {
grid-template-columns: repeat(3, minmax(12rem, 1fr));
gap: 0.5rem;
padding: 0.55rem;
border: 1px solid #cbd5e1;
border: 1px solid var(--panel-border);
border-radius: 5px;
background: #f8fafc;
background: var(--panel-bg-subtle);
}
.distribution-power-summary > div {
@ -398,13 +549,13 @@ a.kpi:hover {
}
.distribution-power-summary span {
color: #475569;
color: var(--text-subtle);
font-size: 0.74rem;
line-height: 1.2;
}
.distribution-power-summary strong {
color: #172033;
color: var(--text-strong);
font-size: 0.95rem;
}
@ -418,13 +569,13 @@ a.kpi:hover {
position: absolute;
z-index: 9;
margin-top: 2rem;
border: 1px solid #cfd7e5;
background: #fff;
border: 1px solid var(--panel-border);
background: var(--panel-bg);
border-radius: 5px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
box-shadow: 0 6px 16px var(--shadow-menu);
padding: 0.55rem;
width: 340px;
color: #1f2937;
color: var(--text-strong);
text-align: left;
}
@ -444,7 +595,7 @@ a.kpi:hover {
.column-settings-close {
border: 0 !important;
background: transparent !important;
color: #4b5563;
color: var(--text-muted);
padding: 0.05rem 0.2rem !important;
font-size: 1rem !important;
line-height: 1;
@ -453,7 +604,7 @@ a.kpi:hover {
.column-settings-explanation {
margin-bottom: 0.4rem;
color: #4b5563;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.3;
}
@ -461,9 +612,11 @@ a.kpi:hover {
.column-settings-search {
width: 100%;
margin-bottom: 0.4rem;
border: 1px solid #c4cddc;
border: 1px solid var(--panel-border-strong);
border-radius: 4px;
padding: 0.3rem 0.4rem;
background: var(--panel-bg);
color: var(--text-strong);
font-size: 0.76rem;
}
@ -473,7 +626,7 @@ a.kpi:hover {
gap: 0.15rem;
max-height: 300px;
overflow: auto;
border: 1px solid #e1e6ef;
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 0.25rem;
}
@ -489,8 +642,8 @@ a.kpi:hover {
}
.column-settings-item.selected {
border-color: #bfdbfe;
background: #eff6ff;
border-color: var(--accent-blue-border);
background: var(--accent-blue-soft-bg);
}
.column-settings-item.dragging {
@ -498,12 +651,12 @@ a.kpi:hover {
}
.column-settings-item.drop-target {
border-color: #2b6cb0;
background: #ebf4ff;
border-color: var(--accent-blue-drop-border);
background: var(--accent-blue-soft-bg);
}
.column-settings-item.locked {
background: #f7fafc;
background: var(--panel-bg-subtle);
}
.column-visibility-button {
@ -530,7 +683,7 @@ a.kpi:hover {
flex: 0 0 1rem;
align-items: center;
justify-content: center;
color: #1d4ed8;
color: var(--accent-blue-marker);
font-weight: 700;
}
@ -540,8 +693,9 @@ a.kpi:hover {
}
.column-settings-order button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 3px;
font-size: 0.72rem;
padding: 0.08rem 0.28rem;
@ -549,8 +703,8 @@ a.kpi:hover {
.tree-grid-wrap {
overflow: auto;
border: 1px solid #d9dee8;
background: #fff;
border: 1px solid var(--panel-border);
background: var(--panel-bg);
width: 100%;
max-width: 100%;
min-width: 0;
@ -560,7 +714,7 @@ a.kpi:hover {
.column-settings-empty {
padding: 0.55rem 0.35rem;
color: #6b7280;
color: var(--text-faint);
font-size: 0.76rem;
}
@ -570,8 +724,8 @@ a.kpi:hover {
}
.column-settings-footer button.primary {
border-color: #2563eb;
background: #2563eb;
border-color: var(--accent-blue);
background: var(--accent-blue);
color: #fff;
}
@ -583,8 +737,8 @@ a.kpi:hover {
}
.project-device-sidebar {
border: 1px solid #d9dee8;
background: #fff;
border: 1px solid var(--panel-border);
background: var(--panel-bg);
padding: 0.6rem;
display: flex;
flex-direction: column;
@ -594,14 +748,17 @@ a.kpi:hover {
.project-device-sidebar h3 {
margin: 0;
font-size: 1rem;
color: var(--text-strong);
}
.project-device-sidebar input,
.project-device-sidebar select {
width: 100%;
border: 1px solid #cfd7e5;
border: 1px solid var(--panel-border-strong);
border-radius: 4px;
padding: 0.25rem 0.35rem;
background: var(--panel-bg);
color: var(--text-strong);
}
.project-device-list {
@ -613,8 +770,9 @@ a.kpi:hover {
}
.project-device-item {
border: 1px solid #d5ddec;
background: #f8faff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg-subtle);
color: var(--text-strong);
text-align: left;
padding: 0.4rem;
border-radius: 4px;
@ -625,8 +783,8 @@ a.kpi:hover {
}
.project-device-item.selected {
border-color: #4c7dd9;
background: #edf3ff;
border-color: var(--accent-blue-strong-border);
background: var(--accent-blue-soft-bg);
}
.project-device-item.dragging {
@ -645,11 +803,13 @@ a.kpi:hover {
flex-direction: column;
gap: 0.2rem;
font-size: 0.82rem;
color: var(--text-strong);
}
.sidebar-actions button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 4px;
padding: 0.3rem 0.45rem;
font-size: 0.82rem;
@ -660,18 +820,19 @@ a.kpi:hover {
min-width: 0;
border-collapse: collapse;
font-size: 0.9rem;
color: var(--text-strong);
}
.tree-grid th,
.tree-grid td {
border: 1px solid #e4e9f2;
border: 1px solid var(--panel-border);
padding: 0.35rem 0.4rem;
vertical-align: middle;
}
.tree-grid th {
width: 1px;
background: #f4f7fb;
background: var(--panel-bg-subtle);
position: sticky;
top: 0;
z-index: 2;
@ -694,8 +855,9 @@ a.kpi:hover {
.tree-grid .header-filter-btn {
display: block;
width: 100%;
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
@ -712,7 +874,7 @@ a.kpi:hover {
border: 0;
background: transparent;
padding: 0;
color: #1f2937;
color: var(--text-strong);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
@ -733,7 +895,7 @@ a.kpi:hover {
max-height: calc(100vh - 7rem);
overflow: auto;
border-radius: 0.5rem;
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
box-shadow: 0 0.75rem 2rem var(--shadow-drawer);
}
.project-device-drawer-header {
@ -744,13 +906,14 @@ a.kpi:hover {
}
.project-device-drawer-header span {
color: #6b7280;
color: var(--text-faint);
font-size: 0.75rem;
}
.project-device-drawer-header button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 4px;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
@ -762,23 +925,24 @@ a.kpi:hover {
}
.tree-grid .sort-indicator {
color: #2563eb;
color: var(--accent-blue);
font-size: 0.85rem;
}
.tree-grid .header-filter-btn.active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
border-color: var(--accent-blue);
color: var(--accent-blue);
background: var(--accent-blue-soft-bg);
}
.tree-grid .header-filter-menu {
position: absolute;
z-index: 7;
margin-top: 0.2rem;
border: 1px solid #cfd7e5;
background: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border: 1px solid var(--panel-border);
background: var(--panel-bg);
color: var(--text-strong);
box-shadow: 0 6px 16px var(--shadow-menu);
width: 300px;
padding: 0.55rem;
text-align: left;
@ -802,7 +966,7 @@ a.kpi:hover {
.tree-grid .header-filter-close {
border: 0;
background: transparent;
color: #4b5563;
color: var(--text-muted);
padding: 0.05rem 0.2rem;
font-size: 1rem;
line-height: 1;
@ -817,13 +981,14 @@ a.kpi:hover {
.tree-grid .header-filter-selection-actions span {
margin-left: auto;
color: #4b5563;
color: var(--text-muted);
}
.tree-grid .header-filter-selection-actions button,
.tree-grid .header-filter-footer button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 3px;
padding: 0.18rem 0.35rem;
font-size: 0.72rem;
@ -832,7 +997,7 @@ a.kpi:hover {
.tree-grid .header-filter-explanation {
margin-bottom: 0.4rem;
color: #4b5563;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.3;
white-space: normal;
@ -840,9 +1005,11 @@ a.kpi:hover {
.tree-grid .header-filter-search {
width: 100%;
border: 1px solid #c4cddc;
border: 1px solid var(--panel-border-strong);
border-radius: 4px;
padding: 0.3rem 0.4rem;
background: var(--panel-bg);
color: var(--text-strong);
font-size: 0.76rem;
}
@ -852,7 +1019,7 @@ a.kpi:hover {
gap: 0.15rem;
max-height: 210px;
overflow: auto;
border: 1px solid #e1e6ef;
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 0.25rem;
}
@ -866,7 +1033,7 @@ a.kpi:hover {
border-radius: 3px;
background: transparent;
padding: 0.25rem 0.3rem;
color: #1f2937;
color: var(--text-strong);
font-weight: 400;
font-size: 0.75rem;
text-align: left;
@ -874,17 +1041,17 @@ a.kpi:hover {
}
.tree-grid .header-filter-item:hover {
background: #f3f4f6;
background: var(--surface-hover);
}
.tree-grid .header-filter-item.selected {
border-color: #bfdbfe;
background: #eff6ff;
border-color: var(--accent-blue-border);
background: var(--accent-blue-soft-bg);
}
.tree-grid .header-filter-empty {
padding: 0.45rem 0.25rem;
color: #6b7280;
color: var(--text-faint);
font-size: 0.74rem;
}
@ -897,8 +1064,8 @@ a.kpi:hover {
}
.tree-grid .header-filter-footer button.primary {
border-color: #2563eb;
background: #2563eb;
border-color: var(--accent-blue);
background: var(--accent-blue);
color: #fff;
}
@ -908,7 +1075,7 @@ a.kpi:hover {
}
.tree-grid .header-filter-warning {
color: #9a3412;
color: var(--grid-warn-strong);
font-size: 0.7rem;
white-space: normal;
}
@ -918,25 +1085,25 @@ a.kpi:hover {
}
.tree-grid .section-row td {
background: #e8eef8;
background: var(--surface-header-row);
font-weight: 600;
}
.tree-grid .structure-component-row td {
padding: 0.38rem 0.6rem;
border-bottom-color: #d8dee9;
border-bottom-color: var(--panel-border);
}
.tree-grid .structure-component-row.headerComponent td {
background: #e2e8f0;
background: var(--surface-component-header);
}
.tree-grid .structure-component-row.groupComponent td {
background: #f8fafc;
background: var(--surface-component-group);
}
.tree-grid .structure-component-row.footerComponent td {
background: #f1f5f9;
background: var(--surface-component-footer);
}
.tree-grid .structure-component-content {
@ -952,7 +1119,7 @@ a.kpi:hover {
}
.tree-grid .structure-component-protection {
color: #475569;
color: var(--text-subtle);
font-size: 0.78rem;
}
@ -969,7 +1136,7 @@ a.kpi:hover {
}
.tree-grid .structure-component-fixed {
color: #64748b;
color: var(--text-faint);
font-size: 0.75rem;
}
@ -990,32 +1157,33 @@ a.kpi:hover {
}
.tree-grid .section-actions button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
padding: 0.2rem 0.45rem;
border-radius: 3px;
font-size: 0.78rem;
}
.tree-grid .summary-row td {
background: #f3f7fd;
background: var(--panel-bg-subtle);
}
.tree-grid .device-row td:first-child {
color: #6b7280;
color: var(--text-faint);
}
.tree-grid .empty-circuit-row td {
background: #f8fbff;
background: var(--panel-bg-subtle);
}
.tree-grid tr.row-selected td {
background: #eaf1ff;
background: var(--surface-selected);
}
.tree-grid .placeholder-row td {
background: #f7f7f7;
color: #6b7280;
background: var(--panel-bg-subtle);
color: var(--text-faint);
font-style: italic;
}
@ -1032,8 +1200,12 @@ a.kpi:hover {
box-shadow 0.12s ease;
}
:root[data-bs-theme="dark"] .tree-grid .cell-protection-trigger {
color: var(--color-accent-pale);
}
.tree-grid .cell-protection-trigger:hover {
background: #eaf6f0;
background: rgba(63, 166, 107, 0.15);
box-shadow: inset 0 0 0 1px var(--color-signal);
}
@ -1054,7 +1226,7 @@ a.kpi:hover {
}
.tree-grid .cell-selected {
outline: 2px solid #4c7dd9;
outline: 2px solid var(--accent-blue-strong-border);
outline-offset: -2px;
}
@ -1065,28 +1237,30 @@ a.kpi:hover {
}
.tree-grid .section-title span {
color: #475569;
color: var(--text-subtle);
font-size: 0.78rem;
font-weight: 500;
}
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline: 2px solid var(--grid-danger);
outline-offset: -2px;
background: #fff7ed !important;
background: var(--grid-warn-bg) !important;
}
.tree-grid .cell-invalid input {
border-color: #c2410c;
border-color: var(--grid-danger);
}
.tree-grid input,
.tree-grid select {
width: 100%;
min-width: 5rem;
border: 1px solid #9fb6e0;
border: 1px solid var(--input-border);
border-radius: 2px;
padding: 0.2rem 0.3rem;
background: var(--panel-bg);
color: var(--text-strong);
}
.tree-grid .action-cell {
@ -1095,26 +1269,27 @@ a.kpi:hover {
}
.tree-grid .action-cell button {
border: 1px solid #c4cddc;
background: #fff;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
padding: 0.2rem 0.45rem;
border-radius: 3px;
font-size: 0.78rem;
}
.tree-grid .drop-target-active {
box-shadow: inset 0 0 0 2px #4c7dd9;
background: #eef4ff !important;
box-shadow: inset 0 0 0 2px var(--accent-blue-strong-border);
background: var(--accent-blue-soft-bg) !important;
}
.tree-grid .drop-target-invalid {
box-shadow: inset 0 0 0 2px #d97706;
background: #fff7ed !important;
box-shadow: inset 0 0 0 2px var(--grid-warn);
background: var(--grid-warn-bg) !important;
}
.tree-grid .drop-target-confirm {
box-shadow: inset 0 0 0 2px #d97706;
background: #fffbeb !important;
box-shadow: inset 0 0 0 2px var(--grid-warn);
background: var(--grid-warn-bg) !important;
}
.tree-grid tr.circuit-insert-before td,
@ -1128,7 +1303,7 @@ a.kpi:hover {
left: -1px;
right: -1px;
top: -2px;
border-top: 4px solid #2563eb;
border-top: 4px solid var(--accent-blue);
pointer-events: none;
}
@ -1141,7 +1316,7 @@ a.kpi:hover {
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 10px solid #2563eb;
border-left: 10px solid var(--accent-blue);
pointer-events: none;
}
@ -1151,7 +1326,7 @@ a.kpi:hover {
left: -1px;
right: -1px;
bottom: -2px;
border-bottom: 4px solid #2563eb;
border-bottom: 4px solid var(--accent-blue);
pointer-events: none;
}
@ -1164,13 +1339,13 @@ a.kpi:hover {
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 10px solid #2563eb;
border-left: 10px solid var(--accent-blue);
pointer-events: none;
}
.drop-hint {
font-size: 0.75rem;
color: #1f4ea3;
color: var(--accent-blue-marker);
font-weight: 600;
}
@ -1178,21 +1353,22 @@ a.kpi:hover {
padding: 0.5rem 0.75rem;
border-radius: 4px;
border: 1px solid transparent;
color: var(--text-strong);
}
.notice.info {
background: #ebf3ff;
border-color: #bad1f7;
background: var(--notice-info-bg);
border-color: var(--notice-info-border);
}
.notice.error {
background: #fdecec;
border-color: #f5b5b5;
background: var(--notice-error-bg);
border-color: var(--notice-error-border);
}
.notice.warning {
background: #fff7ed;
border-color: #fdba74;
background: var(--notice-warning-bg);
border-color: var(--notice-warning-border);
}
.editor-error-notice {
@ -1203,12 +1379,13 @@ a.kpi:hover {
}
.notice.muted {
background: #f6f6f6;
border-color: #e4e4e4;
background: var(--notice-muted-bg);
border-color: var(--notice-muted-border);
color: var(--text-muted);
}
.todo-hint {
color: #6b7280;
color: var(--text-faint);
font-size: 0.8rem;
margin: 0;
}

View file

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Script from "next/script";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
import { AppShell } from "../frontend/components/AppShell";
@ -8,10 +9,33 @@ export const metadata: Metadata = {
description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten",
};
// Keep this key literal in sync with THEME_STORAGE_KEY in ThemeToggle.tsx.
// It must stay inline (not imported) so it runs before hydration and never
// flashes the wrong theme on load.
const THEME_INIT_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem("leistungsbilanz:theme");
var theme =
stored === "dark" || stored === "light"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-bs-theme", theme);
} catch (error) {
// Storage/matchMedia unavailable: fall back to the default light theme.
}
})();
`;
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<html lang="de" suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<AppShell>{children}</AppShell>
</body>
</html>

View file

@ -17,13 +17,13 @@ import {
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
listGlobalDevices,
listProjectDevices,
listProjects,
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
@ -129,7 +129,7 @@ export default function ProjectDetailPage() {
return;
}
Promise.all([
listProjects(),
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
@ -138,7 +138,7 @@ export default function ProjectDetailPage() {
listGlobalDevices(),
])
.then(([
projects,
currentProject,
distributionBoards,
loadedCircuitLists,
loadedFloors,
@ -146,7 +146,6 @@ export default function ProjectDetailPage() {
loadedProjectDevices,
loadedGlobalDevices,
]) => {
const currentProject = projects.find((item) => item.id === projectId) ?? null;
setProject(currentProject);
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);

View file

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
// Liveness probe for the web container itself. The "/health" path is
// rewritten to the API in next.config.mjs, so it cannot answer for this
// process. Kept as a route handler so a probe does not render a page.
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,2 @@
CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint
CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`);

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,13 @@
"when": 1785687503453,
"tag": "0005_stale_gorilla_man",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
}
]
}

View file

@ -43,12 +43,6 @@ export interface CircuitDeviceRowPatchInput {
overriddenFields?: string | null;
}
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
}
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@ -105,15 +99,3 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
return values;
}
export function toCircuitDeviceRowCreateValues(
id: string,
input: CircuitDeviceRowCreateInput
) {
return {
id,
circuitId: input.circuitId,
sortOrder: input.sortOrder,
...toCircuitDeviceRowUpdateValues(input),
};
}

View file

@ -1,4 +1,4 @@
import { and, eq, ne } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
@ -21,6 +21,7 @@ import {
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
type CircuitRow = typeof circuits.$inferSelect;
@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository
) {
return;
}
const duplicate = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
assertEquipmentIdentifierAvailable(
database,
circuit.circuitListId,
equipmentIdentifier,
circuit.id
);
}
}

View file

@ -27,6 +27,7 @@ import {
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository
if (existingCircuit) {
throw new Error("Circuit id already exists.");
}
const duplicateIdentifier = database
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
assertEquipmentIdentifierAvailable(
database,
snapshot.circuitListId,
snapshot.equipmentIdentifier
);
if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id);

View file

@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
@ -94,6 +95,11 @@ export class DistributionBoardComponentStructureProjectCommandRepository
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
assertEquipmentIdentifierAvailable(
database,
snapshot.component.circuitListId,
snapshot.component.equipmentIdentifier
);
database
.insert(distributionBoardComponents)
.values(snapshot.component)
@ -187,6 +193,17 @@ export class DistributionBoardComponentStructureProjectCommandRepository
"Distribution-board component changed before update."
);
}
if (
target.component.equipmentIdentifier !==
expected.component.equipmentIdentifier
) {
assertEquipmentIdentifierAvailable(
database,
expected.component.circuitListId,
target.component.equipmentIdentifier,
expected.component.id
);
}
database
.update(distributionBoardComponents)
.set(target.component)

View file

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitListEquipmentIdentifiers } from "../schema/circuit-list-equipment-identifiers.js";
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toLowerCase();
}
/**
* The DB-level normalized unique index uses SQLite's built-in lower(),
* which only folds ASCII a-z and leaves German characters (Ä/Ö/Ü/ß/)
* untouched, so it alone would let e.g. "Ä1" and "ä1" coexist. This check
* normalizes with JS's Unicode-aware toLowerCase() against every
* identifier already registered for the circuit list (circuits and
* distribution-board components share one BMK namespace via
* circuit_list_equipment_identifiers), catching what the DB index cannot.
*/
export function assertEquipmentIdentifierAvailable(
database: AppDatabase,
circuitListId: string,
equipmentIdentifier: string,
excludeOwnerId?: string
): void {
const candidate = normalizeEquipmentIdentifier(equipmentIdentifier);
const existing = database
.select({
ownerId: circuitListEquipmentIdentifiers.ownerId,
equipmentIdentifier: circuitListEquipmentIdentifiers.equipmentIdentifier,
})
.from(circuitListEquipmentIdentifiers)
.where(eq(circuitListEquipmentIdentifiers.circuitListId, circuitListId))
.all();
const duplicate = existing.some(
(row) =>
row.ownerId !== excludeOwnerId &&
normalizeEquipmentIdentifier(row.equipmentIdentifier) === candidate
);
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}

View file

@ -212,7 +212,9 @@ function replaceExternalState(
if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run();
}
database.insert(externalModelObjects).values(target.objects).run();
if (target.objects.length) {
database.insert(externalModelObjects).values(target.objects).run();
}
}
function decodeCanonicalBase64(value: string) {

View file

@ -32,6 +32,7 @@ import {
loadExpectedExternalObjectTransitions,
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore
@ -90,12 +91,11 @@ export class ExternalObjectNewCircuitProjectCommandRepository
.where(eq(circuits.id, circuit.id)).get()) {
throw new Error("External circuit id already exists.");
}
if (database.select({ id: circuits.id }).from(circuits).where(and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
)).get()) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
assertEquipmentIdentifierAvailable(
database,
circuit.circuitListId,
circuit.equipmentIdentifier
);
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists.");

View file

@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository
);
for (const assignment of input.command.payload.rows) {
const values: typeof assignment.target & {
manualQuantity?: number;
} = { ...assignment.target };
if (assignment.target.quantity !== assignment.expected.quantity) {
const externalTotal = tx
.select({ planningValues: externalModelObjects.planningValues })
.from(externalModelObjects)
.where(
eq(externalModelObjects.circuitDeviceRowId, assignment.rowId)
)
.all()
.reduce(
(sum, object) => sum + object.planningValues.effectiveQuantity,
0
);
const manualQuantity = assignment.target.quantity - externalTotal;
if (manualQuantity < 0) {
throw new Error(
"Synchronized quantity is below the total quantity of linked external objects."
);
}
values.manualQuantity = manualQuantity;
}
const updated = tx
.update(circuitDeviceRows)
.set(assignment.target)
.set(values)
.where(eq(circuitDeviceRows.id, assignment.rowId))
.run();
if (updated.changes !== 1) {

View file

@ -1,35 +1,38 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js";
import { projectDevices } from "./project-devices.js";
import { rooms } from "./rooms.js";
export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
id: text("id").primaryKey(),
circuitId: text("circuit_id")
.notNull()
.references(() => circuits.id, { onDelete: "cascade" }),
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
onDelete: "set null",
}),
sortOrder: integer("sort_order").notNull().default(0),
name: text("name").notNull(),
displayName: text("display_name").notNull(),
phaseType: text("phase_type"),
connectionKind: text("connection_kind"),
costGroup: text("cost_group"),
category: text("category"),
level: text("level"),
roomId: text("room_id").references(() => rooms.id, {
onDelete: "set null",
}),
roomNumberSnapshot: text("room_number_snapshot"),
roomNameSnapshot: text("room_name_snapshot"),
quantity: integer("quantity").notNull(),
manualQuantity: integer("manual_quantity").notNull().default(0),
powerPerUnit: real("power_per_unit").notNull(),
simultaneityFactor: real("simultaneity_factor").notNull(),
cosPhi: real("cos_phi"),
remark: text("remark"),
overriddenFields: text("overridden_fields"),
});
export const circuitDeviceRows = sqliteTable(
"circuit_device_rows",
{
id: text("id").primaryKey(),
circuitId: text("circuit_id")
.notNull()
.references(() => circuits.id, { onDelete: "cascade" }),
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
onDelete: "set null",
}),
sortOrder: integer("sort_order").notNull().default(0),
name: text("name").notNull(),
displayName: text("display_name").notNull(),
phaseType: text("phase_type"),
connectionKind: text("connection_kind"),
costGroup: text("cost_group"),
category: text("category"),
level: text("level"),
roomId: text("room_id").references(() => rooms.id, {
onDelete: "set null",
}),
roomNumberSnapshot: text("room_number_snapshot"),
roomNameSnapshot: text("room_name_snapshot"),
quantity: integer("quantity").notNull(),
manualQuantity: integer("manual_quantity").notNull().default(0),
powerPerUnit: real("power_per_unit").notNull(),
simultaneityFactor: real("simultaneity_factor").notNull(),
cosPhi: real("cos_phi"),
remark: text("remark"),
overriddenFields: text("overridden_fields"),
},
(table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)]
);

View file

@ -1,4 +1,4 @@
import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js";
@ -26,6 +26,9 @@ export const circuits = sqliteTable(
isReserve: integer("is_reserve").notNull().default(0),
remark: text("remark"),
},
(table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
(table) => [
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
index("circuits_section_id_idx").on(table.sectionId),
]
);

View file

@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue(
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`);
}
if (field === "simultaneityFactor" && value > 1) {
throw new Error("simultaneityFactor must not exceed 1.");
}
return;
}
if (field === "cosPhi") {

View file

@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand(
row.simultaneityFactor,
"row.simultaneityFactor"
);
if (row.simultaneityFactor > 1) {
throw new Error("row.simultaneityFactor must not exceed 1.");
}
if (row.cosPhi !== null) {
assertFiniteNumber(row.cosPhi, "row.cosPhi");
if (row.cosPhi <= 0) {

View file

@ -1,24 +0,0 @@
export interface CircuitDeviceRow {
id: string;
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
manualQuantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}

View file

@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand(
if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId);
}
if (JSON.stringify(expected) === JSON.stringify(target)) {
if (circuitProtectionSnapshotsEqual(expected, target)) {
throw new Error("Circuit protection update must change state.");
}
}
function circuitProtectionSnapshotsEqual(
left: CircuitProtectionSnapshot | null,
right: CircuitProtectionSnapshot | null
): boolean {
if (left === null || right === null) {
return left === right;
}
return (
left.circuitId === right.circuitId &&
left.type === right.type &&
left.ratedCurrentA === right.ratedCurrentA &&
left.fuseUtilizationCategory === right.fuseUtilizationCategory &&
left.tripCharacteristic === right.tripCharacteristic &&
left.rcdType === right.rcdType &&
left.ratedResidualCurrentMa === right.ratedResidualCurrentMa
);
}
export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
): asserts value is CircuitProtectionSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||

View file

@ -1,9 +0,0 @@
export interface CircuitSection {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}

View file

@ -1,19 +0,0 @@
export interface Circuit {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}

View file

@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess(
quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,

View file

@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { listProjects } from "../utils/api";
import type { ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle";
const PROJECT_SECTIONS = [
{ id: "verlauf", label: "Verlauf", icon: "↺" },
@ -135,6 +136,9 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</>
)}
</nav>
<div className="sidebar-footer">
<ThemeToggle />
</div>
</aside>
);
}

View file

@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
export const THEME_STORAGE_KEY = "leistungsbilanz:theme";
type Theme = "light" | "dark";
function applyTheme(theme: Theme) {
document.documentElement.setAttribute("data-bs-theme", theme);
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");
// The inline script in layout.tsx already applied the persisted/system
// theme before hydration; read it back so the toggle starts in sync
// instead of flashing to "light" first.
useEffect(() => {
const current = document.documentElement.getAttribute("data-bs-theme");
setTheme(current === "dark" ? "dark" : "light");
}, []);
useEffect(() => {
function handleStorage(event: StorageEvent) {
if (
event.key === THEME_STORAGE_KEY &&
(event.newValue === "dark" || event.newValue === "light")
) {
setTheme(event.newValue);
applyTheme(event.newValue);
}
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
function toggleTheme() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
applyTheme(next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Private browsing or disabled storage: theme still applies for
// this page load, just without persistence across reloads.
}
}
return (
<button
aria-pressed={theme === "dark"}
className="sidebar-theme-toggle"
onClick={toggleTheme}
type="button"
>
<span className="sidebar-icon" aria-hidden="true">
{theme === "dark" ? "☀" : "☾"}
</span>
{theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
</button>
);
}

View file

@ -268,6 +268,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Synchronous re-entry guard: isSaving is React state and only reflects
// reality after the next render, so a second click/drop fired within the
// same tick could otherwise race past it and double-submit a command.
const commandInFlightRef = useRef(false);
const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
@ -723,6 +727,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
);
}, [data]);
// Clears the sidebar's target selection once it no longer resolves to a
// real section/circuit (e.g. deleted, moved or renumbered elsewhere)
// instead of silently holding a stale id after a tree reload.
useEffect(() => {
if (!data) {
return;
}
if (
targetSectionId &&
!data.sections.some((section) => section.id === targetSectionId)
) {
setTargetSectionId(null);
}
if (
targetCircuitId &&
!circuitOptions.some((option) => option.id === targetCircuitId)
) {
setTargetCircuitId(null);
}
}, [data, circuitOptions, targetSectionId, targetCircuitId]);
const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [],
[data]
@ -1046,6 +1071,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Runs a normal command. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try {
setError(null);
setIsSaving(true);
@ -1056,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false });
setError(message);
} finally {
commandInFlightRef.current = false;
setIsSaving(false);
}
}
@ -1063,6 +1093,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try {
setError(null);
setHistoryBusy(true);
@ -1088,6 +1122,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false });
setError(message);
} finally {
commandInFlightRef.current = false;
setIsSaving(false);
setHistoryBusy(false);
}
@ -1744,7 +1779,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
throw new Error("Bereich wurde nicht gefunden.");
}
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const isDeviceField = deviceFieldKeys.has(key);
@ -1933,7 +1968,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({
label: "Stromkreis hinzufügen",
redo: async () => {
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const circuit = createCircuitSnapshot({
sectionId,
@ -2127,7 +2162,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
throw new Error("Der Zielbereich ist ungültig.");
}
const next = await getNextCircuitIdentifier(sectionId);
const next = await getNextCircuitIdentifier(projectId, sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const circuit = createCircuitSnapshot(
@ -2538,7 +2573,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({
label: newCircuitLabel,
redo: async () => {
const next = await getNextCircuitIdentifier(intent.sectionId);
const next = await getNextCircuitIdentifier(projectId, intent.sectionId);
const sortOrder =
intent.targetCircuitId && intent.placement
? getAdjacentInsertionSortOrder(
@ -3699,6 +3734,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
@ -3716,6 +3752,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
@ -3733,6 +3770,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
tabIndex={-1}
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
@ -3758,6 +3796,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
@ -3815,13 +3854,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
Gruppen-FI hinzufügen
</button>
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={hasActiveSortOrFilter}
disabled={hasActiveSortOrFilter || isSaving}
onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
@ -4426,6 +4470,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
@ -4433,6 +4478,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Stromkreis löschen
@ -4440,7 +4486,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</>
) : null}
{row.device ? (
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
) : null}

View file

@ -1,6 +1,6 @@
"use client";
import React, { type FormEvent, type ReactNode } from "react";
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react";
interface FormModalProps {
children: ReactNode;
@ -14,6 +14,9 @@ interface FormModalProps {
title: string;
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function FormModal({
children,
description,
@ -25,11 +28,56 @@ export function FormModal({
submitLabel,
title,
}: FormModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Focuses the dialog on open and returns focus to the element that
// triggered it on close, so keyboard users never lose their place in the
// grid behind the backdrop.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const firstFocusable =
dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
firstFocusable?.focus();
return () => {
previouslyFocused?.focus();
};
}, []);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
if (!isSaving) {
event.stopPropagation();
onClose();
}
return;
}
if (event.key !== "Tab" || !dialogRef.current) {
return;
}
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
);
if (focusable.length === 0) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
return (
<>
<div
aria-modal="true"
className="modal fade show d-block"
onKeyDown={handleKeyDown}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>

View file

@ -7,6 +7,7 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput {
name: string;
@ -131,319 +132,278 @@ export function ProjectSettingsModal({
}
return (
<>
<div
aria-labelledby="project-settings-title"
aria-modal="true"
className="modal fade show d-block"
role="dialog"
tabIndex={-1}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<form className="modal-content" onSubmit={handleSubmit}>
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id="project-settings-title">
Projekteinstellungen
</h2>
<p className="text-secondary small mb-0">
Stammdaten und elektrische Standardwerte des Projekts
</p>
</div>
<button
aria-label="Schließen"
className="btn-close"
disabled={isSaving}
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="project-name">
Projektname
</label>
<input
autoFocus
className="form-control"
id="project-name"
maxLength={200}
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="internal-project-number">
Projektnummer intern
</label>
<input
className="form-control"
id="internal-project-number"
maxLength={100}
onChange={(event) =>
setInternalProjectNumber(event.target.value)
}
value={internalProjectNumber}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="external-project-number">
Projektnummer extern
</label>
<input
className="form-control"
id="external-project-number"
maxLength={100}
onChange={(event) =>
setExternalProjectNumber(event.target.value)
}
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<fieldset>
<legend className="form-label mb-1">
Verwendete Netzarten
</legend>
<p className="form-text mt-0">
Nur ausgewählte Netzarten stehen bei Verteilungen zur
Auswahl. Bereits verwendete Netzarten können nicht
deaktiviert werden.
</p>
<div className="row g-2">
{distributionBoardSupplyTypes.map((supplyType) => (
<div
className="col-12 col-md-6"
key={supplyType}
>
<label className="form-check">
<input
checked={enabledDistributionBoardSupplyTypes.includes(
supplyType
)}
className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes(
supplyType
)}
onChange={() => toggleSupplyType(supplyType)}
type="checkbox"
/>
<span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]}
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span>
</label>
</div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div>
) : null}
</fieldset>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
</label>
<input
className="form-control"
id="building-owner"
maxLength={200}
onChange={(event) => setBuildingOwner(event.target.value)}
value={buildingOwner}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="project-description">
Beschreibung
</label>
<textarea
className="form-control"
id="project-description"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
<div className="col-12">
<FormModal
description="Stammdaten und elektrische Standardwerte des Projekts"
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!isValid}
submitLabel="Einstellungen speichern"
title="Projekteinstellungen"
>
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="project-name">
Projektname
</label>
<input
autoFocus
className="form-control"
id="project-name"
maxLength={200}
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="internal-project-number">
Projektnummer intern
</label>
<input
className="form-control"
id="internal-project-number"
maxLength={100}
onChange={(event) =>
setInternalProjectNumber(event.target.value)
}
value={internalProjectNumber}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="external-project-number">
Projektnummer extern
</label>
<input
className="form-control"
id="external-project-number"
maxLength={100}
onChange={(event) =>
setExternalProjectNumber(event.target.value)
}
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<fieldset>
<legend className="form-label mb-1">
Verwendete Netzarten
</legend>
<p className="form-text mt-0">
Nur ausgewählte Netzarten stehen bei Verteilungen zur
Auswahl. Bereits verwendete Netzarten können nicht
deaktiviert werden.
</p>
<div className="row g-2">
{distributionBoardSupplyTypes.map((supplyType) => (
<div
className="col-12 col-md-6"
key={supplyType}
>
<label className="form-check">
<input
checked={isPublicBuilding}
checked={enabledDistributionBoardSupplyTypes.includes(
supplyType
)}
className="form-check-input"
id="public-building"
onChange={(event) =>
setIsPublicBuilding(event.target.checked)
}
disabled={usedDistributionBoardSupplyTypes.includes(
supplyType
)}
onChange={() => toggleSupplyType(supplyType)}
type="checkbox"
/>
<span className="form-check-label">
Öffentliches Gebäude
{distributionBoardSupplyTypeLabels[supplyType]}
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span>
</label>
<div className="form-text ms-4">
Wird bei der späteren Leitungsauslegung berücksichtigt,
insbesondere bei der Auswahl halogenfreier Kabel und
Leitungen.
</div>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="single-phase-voltage">
Standardspannung 1-phasig [V]
</label>
<input
className="form-control"
id="single-phase-voltage"
min="1"
onChange={(event) =>
setSinglePhaseVoltageV(event.target.value)
}
required
type="number"
value={singlePhaseVoltageV}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="three-phase-voltage">
Standardspannung 3-phasig [V]
</label>
<input
className="form-control"
id="three-phase-voltage"
min="1"
onChange={(event) =>
setThreePhaseVoltageV(event.target.value)
}
required
type="number"
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>
<p className="text-secondary small">
Der Export enthält den vollständigen unterstützten
Projektzustand. Beim Duplizieren bleibt dieses Projekt
unverändert.
</p>
<button
className="btn btn-outline-primary mb-3"
disabled={isSaving}
onClick={() => void onExport()}
type="button"
>
Projektdatei herunterladen
</button>
<label className="form-label d-block" htmlFor="project-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
id="project-import"
onChange={(event) =>
void handleTransferFile(event.target.files?.[0])
}
type="file"
/>
{transferFilename ? (
<div className="form-text">{transferFilename} ist bereit.</div>
) : null}
{fileError ? (
<div className="text-danger small mt-1">{fileError}</div>
) : null}
<div className="d-flex flex-wrap gap-3 mt-3">
<label className="form-check">
<input
checked={importMode === "duplicate"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("duplicate")}
type="radio"
/>
<span className="form-check-label">
Als neues Projekt duplizieren
</span>
</label>
<label className="form-check">
<input
checked={importMode === "replace"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("replace")}
type="radio"
/>
<span className="form-check-label">
Dieses Projekt ersetzen
</span>
</label>
</div>
{importMode === "replace" ? (
<label className="form-check mt-2">
<input
checked={replaceConfirmed}
className="form-check-input"
onChange={(event) =>
setReplaceConfirmed(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label text-danger">
Ich bestätige, dass der aktuelle Projektstand ersetzt
wird.
</span>
</label>
) : null}
<button
className="btn btn-outline-danger mt-3"
disabled={
isSaving ||
transfer === null ||
(importMode === "replace" && !replaceConfirmed)
}
onClick={() =>
transfer === null
? undefined
: void onImport(transfer, importMode)
}
type="button"
>
Projektdatei importieren
</button>
</div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-outline-secondary"
disabled={isSaving}
onClick={onClose}
type="button"
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={isSaving || !isValid}
type="submit"
>
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
) : null}
</fieldset>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
</label>
<input
className="form-control"
id="building-owner"
maxLength={200}
onChange={(event) => setBuildingOwner(event.target.value)}
value={buildingOwner}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="project-description">
Beschreibung
</label>
<textarea
className="form-control"
id="project-description"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
<div className="col-12">
<label className="form-check">
<input
checked={isPublicBuilding}
className="form-check-input"
id="public-building"
onChange={(event) =>
setIsPublicBuilding(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label">
Öffentliches Gebäude
</span>
</label>
<div className="form-text ms-4">
Wird bei der späteren Leitungsauslegung berücksichtigt,
insbesondere bei der Auswahl halogenfreier Kabel und
Leitungen.
</div>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="single-phase-voltage">
Standardspannung 1-phasig [V]
</label>
<input
className="form-control"
id="single-phase-voltage"
min="1"
onChange={(event) =>
setSinglePhaseVoltageV(event.target.value)
}
required
type="number"
value={singlePhaseVoltageV}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="three-phase-voltage">
Standardspannung 3-phasig [V]
</label>
<input
className="form-control"
id="three-phase-voltage"
min="1"
onChange={(event) =>
setThreePhaseVoltageV(event.target.value)
}
required
type="number"
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>
<p className="text-secondary small">
Der Export enthält den vollständigen unterstützten
Projektzustand. Beim Duplizieren bleibt dieses Projekt
unverändert.
</p>
<button
className="btn btn-outline-primary mb-3"
disabled={isSaving}
onClick={() => void onExport()}
type="button"
>
Projektdatei herunterladen
</button>
<label className="form-label d-block" htmlFor="project-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
id="project-import"
onChange={(event) =>
void handleTransferFile(event.target.files?.[0])
}
type="file"
/>
{transferFilename ? (
<div className="form-text">{transferFilename} ist bereit.</div>
) : null}
{fileError ? (
<div className="text-danger small mt-1">{fileError}</div>
) : null}
<div className="d-flex flex-wrap gap-3 mt-3">
<label className="form-check">
<input
checked={importMode === "duplicate"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("duplicate")}
type="radio"
/>
<span className="form-check-label">
Als neues Projekt duplizieren
</span>
</label>
<label className="form-check">
<input
checked={importMode === "replace"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("replace")}
type="radio"
/>
<span className="form-check-label">
Dieses Projekt ersetzen
</span>
</label>
</div>
{importMode === "replace" ? (
<label className="form-check mt-2">
<input
checked={replaceConfirmed}
className="form-check-input"
onChange={(event) =>
setReplaceConfirmed(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label text-danger">
Ich bestätige, dass der aktuelle Projektstand ersetzt
wird.
</span>
</label>
) : null}
<button
className="btn btn-outline-danger mt-3"
disabled={
isSaving ||
transfer === null ||
(importMode === "replace" && !replaceConfirmed)
}
onClick={() =>
transfer === null
? undefined
: void onImport(transfer, importMode)
}
type="button"
>
Projektdatei importieren
</button>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
</FormModal>
);
}

View file

@ -688,9 +688,9 @@ export function deleteCircuitCommand(
);
}
export function getNextCircuitIdentifier(sectionId: string) {
export function getNextCircuitIdentifier(projectId: string, sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier`
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier`
);
}

View file

@ -396,6 +396,16 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
if (trimmed === "") {
return undefined;
}
// A "." is ambiguous in German number entry: it could be a decimal point
// (English convention) or a thousands separator (e.g. "1.500" meaning
// one thousand five hundred). Silently guessing either way risks a wrong
// value entering the power balance without any visible error, so "."
// is rejected outright and "," is the only accepted decimal separator.
if (trimmed.includes(".")) {
throw new Error(
`Ungültiger Zahlenwert in ${cellKey}: Dezimalstellen mit Komma eingeben.`
);
}
const parsed = Number(trimmed.replace(",", "."));
if (Number.isNaN(parsed)) {
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);

View file

@ -169,13 +169,6 @@ function makeVisibleGridRow(
return { rowKey, rowType, sectionId, circuit, device, cells };
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
return buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
}
export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[],
structure: {

View file

@ -259,22 +259,28 @@ export function buildCircuitGroupRenumberPlan(
targetGroupNumber,
expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: group.circuits.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
circuits: [...group.circuits]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
targetGroupNumber,
circuitNumber
),
};
}),
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
category,
targetGroupNumber,
circuitNumber
),
};
}),
components: group.components.map((component) => ({
componentId: component.id,
expectedEquipmentIdentifier: component.equipmentIdentifier,

View file

@ -23,9 +23,13 @@ export function buildCircuitSectionRenumberAssignments(
)
)
);
const orderedCircuits = [...targetSection.circuits].sort(
(left, right) =>
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
for (const circuit of orderedCircuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)

View file

@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
component,
});
}
for (const circuit of section.circuits) {
for (const circuit of ordered(section.circuits)) {
rows.push({
rowKey: `circuit-block:${circuit.id}`,
rowType: "circuitBlock",

View file

@ -47,10 +47,24 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
"project-room.update": "Raum bearbeitet",
"project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":
"Externes Objekt aus erzeugtem Stromkreis gelöst",
"external-object.assign-to-new-row":
"Externes Objekt in neue Gerätezeile übernommen",
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
};
export function getProjectRevisionSourceLabel(

View file

@ -0,0 +1,27 @@
import { createLogger, toErrorMeta } from "./shared/logging/logger";
export function registerNodeInstrumentation() {
const logger = createLogger("web");
const heartbeatIntervalMs = 5 * 60 * 1000;
logger.info("web server starting", { pid: process.pid });
process.on("uncaughtException", (error) => {
logger.error("uncaught exception, exiting", toErrorMeta(error));
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
process.exit(1);
});
setInterval(() => {
const memory = process.memoryUsage();
logger.verbose("heartbeat", {
uptimeSeconds: Math.round(process.uptime()),
rssMb: Math.round(memory.rss / 1024 / 1024),
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
});
}, heartbeatIntervalMs).unref();
}

8
src/instrumentation.ts Normal file
View file

@ -0,0 +1,8 @@
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") {
return;
}
const { registerNodeInstrumentation } = await import("./instrumentation-node");
registerNodeInstrumentation();
}

24
src/proxy.ts Normal file
View file

@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) {
// Probes hit "/web-health" and are excluded by the matcher below. The
// User-Agent guard stays as a fallback for anything else that polls
// without one, so real navigation isn't drowned out in the logs.
if (request.headers.get("user-agent")) {
logger.info("page request", {
method: request.method,
path: request.nextUrl.pathname,
});
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
],
};

View file

@ -1,10 +1,21 @@
import type { Request, Response } from "express";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../composition/application-repositories.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
const { projectId, sectionId } = req.params;
if (typeof projectId !== "string" || typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const section = await circuitSectionRepository.findById(sectionId);
const list = section
? await circuitListRepository.findById(projectId, section.circuitListId)
: null;
if (!section || !list) {
return res.status(404).json({ error: "Section not found" });
}
try {
const nextIdentifier =

View file

@ -34,11 +34,12 @@ export async function updateGlobalDevice(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() });
}
await globalDeviceRepository.update(globalDeviceId, parsed.data);
const row = await globalDeviceRepository.findById(globalDeviceId);
if (!row) {
const existing = await globalDeviceRepository.findById(globalDeviceId);
if (!existing) {
return res.status(404).json({ error: "Global device not found" });
}
await globalDeviceRepository.update(globalDeviceId, parsed.data);
const row = await globalDeviceRepository.findById(globalDeviceId);
return res.json(row);
}

View file

@ -1,26 +1,79 @@
import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
const logger = createLogger("api");
const app = express();
const port = Number(process.env.PORT || 3000);
const heartbeatIntervalMs = 5 * 60 * 1000;
app.use(express.json({ limit: "25mb" }));
app.use((req, res, next) => {
if (req.path === "/health") {
next();
return;
}
const startedAt = Date.now();
logger.debug("request started", { method: req.method, path: req.originalUrl });
res.on("finish", () => {
const meta = {
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - startedAt,
};
if (res.statusCode >= 500) logger.error("request completed", meta);
else if (res.statusCode >= 400) logger.warn("request completed", meta);
else logger.info("request completed", meta);
});
res.on("close", () => {
if (!res.writableEnded) {
logger.warn("request aborted before response finished", {
method: req.method,
path: req.originalUrl,
durationMs: Date.now() - startedAt,
});
}
});
next();
});
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use(errorMiddleware);
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
process.on("uncaughtException", (error) => {
logger.error("uncaught exception, exiting", toErrorMeta(error));
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
process.exit(1);
});
process.on("SIGTERM", () => logger.info("received SIGTERM"));
process.on("SIGINT", () => logger.info("received SIGINT"));
setInterval(() => {
const memory = process.memoryUsage();
logger.verbose("heartbeat", {
uptimeSeconds: Math.round(process.uptime()),
rssMb: Math.round(memory.rss / 1024 / 1024),
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
});
}, heartbeatIntervalMs).unref();
app.listen(port, () => {
logger.info("server started", { port });
});

View file

@ -1,12 +1,19 @@
import type { NextFunction, Request, Response } from "express";
import { createLogger, toErrorMeta } from "../../shared/logging/logger.js";
const logger = createLogger("api");
export function errorMiddleware(
error: unknown,
_req: Request,
req: Request,
res: Response,
_next: NextFunction
) {
console.error(error);
logger.error("request handler threw", {
method: req.method,
path: req.originalUrl,
...toErrorMeta(error),
});
res.status(500).json({ error: "Internal Server Error" });
}

View file

@ -1,9 +0,0 @@
import { Router } from "express";
import {
getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js";
export const circuitRouter = Router();
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);

View file

@ -16,6 +16,7 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import {
getProjectHistory,
listProjectRevisions,
@ -95,6 +96,10 @@ projectRouter.delete(
);
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier
);
projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor);

View file

@ -0,0 +1,77 @@
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
const LEVEL_SEVERITY: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
verbose: 3,
debug: 4,
};
const DEFAULT_LEVEL: LogLevel = "info";
export function resolveLogLevel(raw: string | undefined): LogLevel {
const candidate = raw?.trim().toLowerCase();
if (candidate && candidate in LEVEL_SEVERITY) {
return candidate as LogLevel;
}
return DEFAULT_LEVEL;
}
export function toErrorMeta(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
errorName: error.name,
errorMessage: error.message,
stack: error.stack,
};
}
return { error: typeof error === "string" ? error : JSON.stringify(error) };
}
export interface Logger {
error(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
verbose(message: string, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
}
export function createLogger(
scope: string,
options?: { level?: LogLevel }
): Logger {
const threshold = options?.level ?? resolveLogLevel(process.env.LOG_LEVEL);
const write = (level: LogLevel, message: string, meta?: Record<string, unknown>) => {
if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) {
return;
}
const timestamp = new Date().toISOString();
let line: string;
try {
line = JSON.stringify({ timestamp, level, scope, message, ...meta });
} catch {
line = JSON.stringify({
timestamp,
level,
scope,
message,
logError: "failed to serialize log metadata",
});
}
if (level === "error" || level === "warn") {
console.error(line);
} else {
console.log(line);
}
};
return {
error: (message, meta) => write("error", message, meta),
warn: (message, meta) => write("warn", message, meta),
info: (message, meta) => write("info", message, meta),
verbose: (message, meta) => write("verbose", message, meta),
debug: (message, meta) => write("debug", message, meta),
};
}

View file

@ -1,5 +1,10 @@
import { z } from "zod";
// Base64 length of the 18 MiB transport limit documented for CSV uploads
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
// padding). Keep in sync with the controller's raw byte-length check.
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
export const updateExternalCsvConfigurationSchema = z
.object({
expectedRevision: z.number().int().nonnegative(),
@ -10,7 +15,7 @@ export const updateExternalCsvConfigurationSchema = z
export const previewExternalCsvSchema = z
.object({
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(24_000_000),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
})
.strict();
@ -21,7 +26,7 @@ export const applyExternalInitialImportSchema = z.object({
expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(24_000_000),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1),

View file

@ -1,15 +1,15 @@
import { z } from "zod";
export const createGlobalDeviceSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
category: z.string().optional(),
name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200),
category: z.string().max(100).optional(),
quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1),
phaseCount: z.union([z.literal(1), z.literal(3)]),
powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(),
note: z.string().max(2000).optional(),
}).strict();
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;

View file

@ -4,16 +4,16 @@ 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),
connectionKind: z.string().optional(),
costGroup: z.string().optional(),
name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200),
connectionKind: z.string().max(100).optional(),
costGroup: z.string().max(100).optional(),
category: z.enum(circuitGroupCategories),
quantity: z.number().min(0),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().optional(),
remark: z.string().max(2000).optional(),
}).strict();
export const updateProjectDeviceSchema = createProjectDeviceSchema;

View file

@ -36,7 +36,7 @@ export const updateProjectSettingsSchema = z
export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
name: z.string().trim().min(1).max(200),
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
@ -67,7 +67,7 @@ export const deleteDistributionBoardSchema = z
export const createFloorSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
name: z.string().trim().min(1).max(200),
})
.strict();
@ -83,8 +83,8 @@ export const createRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).optional(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();
@ -92,8 +92,8 @@ export const updateRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();

View file

@ -187,12 +187,25 @@ describe("circuit grid model", () => {
});
it("parses numeric drafts and rejects invalid values", () => {
assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
assert.equal(parseNumeric("quantity", " 1500 "), 1500);
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
});
it("rejects a dot instead of silently misreading it as a thousands separator", () => {
// "1.500" typed with German thousands-separator intent (meaning 1500)
// must never silently become 1.5 — it must fail loudly instead.
assert.throws(
() => parseNumeric("cableLength", "1.500"),
/Dezimalstellen mit Komma eingeben/
);
assert.throws(
() => parseNumeric("powerPerUnit", "2.5"),
/Dezimalstellen mit Komma eingeben/
);
});
it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null,

View file

@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildVisibleGridRows,
buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections,
getDistinctFilterValues,
@ -101,7 +100,7 @@ describe("circuit grid projection", () => {
];
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
const rows = buildVisibleGridRows(projectedSections);
const rows = buildVisibleGridRowsWithStructure(projectedSections, { headerComponents: [], footerComponents: [] });
assert.equal(projectedSections.length, 2);
assert.deepEqual(
@ -150,7 +149,7 @@ describe("circuit grid projection", () => {
});
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRows(sections);
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
@ -191,7 +190,7 @@ describe("circuit grid projection", () => {
},
})),
}));
const rows = buildVisibleGridRows(protectedSections);
const rows = buildVisibleGridRowsWithStructure(protectedSections, { headerComponents: [], footerComponents: [] });
const protectionValue = (rowType: string) =>
rows
.find((row) => row.rowType === rowType)

View file

@ -580,4 +580,43 @@ describe("circuit structure project-command repository", () => {
fixture.context.close();
}
});
it("rejects a BMK that differs from an existing one only by German umlaut casing", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-original",
equipmentIdentifier: "-1F9Ä",
deviceRows: [],
})
),
});
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-duplicate",
equipmentIdentifier: "-1f9ä",
deviceRows: [],
})
),
}),
/Duplicate equipmentIdentifier in circuit list\./
);
} finally {
fixture.context.close();
}
});
});

View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getNextCircuitIdentifier } from "../src/server/controllers/circuit.controller.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../src/server/composition/application-repositories.js";
import { circuitNumberingService } from "../src/server/composition/circuit-numbering-service.js";
function createMockResponse() {
let statusCode = 200;
let body: unknown;
return {
response: {
status(code: number) {
statusCode = code;
return this;
},
json(value: unknown) {
body = value;
return this;
},
},
getStatusCode: () => statusCode,
getBody: () => body,
};
}
describe("circuit controller", () => {
it("returns the next identifier when the section belongs to the project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
getNextIdentifier: circuitNumberingService.getNextIdentifier,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
circuitListRepository.findById = async (projectId: string, circuitListId: string) =>
projectId === "project-1" && circuitListId === "list-1"
? ({ id: "list-1", projectId: "project-1" } as never)
: null;
circuitNumberingService.getNextIdentifier = async () => "-1F3";
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
circuitNumberingService.getNextIdentifier = originals.getNextIdentifier;
}
assert.deepEqual(mock.getBody(), {
sectionId: "section-1",
nextIdentifier: "-1F3",
});
});
it("returns 404 instead of leaking numbering state for a section from another project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
// The section exists, but its circuit list does not belong to the requesting project.
circuitListRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "foreign-project", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
it("returns 404 for a section that does not exist", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
};
circuitSectionRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "missing" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
});

View file

@ -580,7 +580,7 @@ describe("distribution-board component structure project command", () => {
snapshot
),
}),
/UNIQUE constraint failed/
/Duplicate equipmentIdentifier in circuit list\./
);
assert.equal(
context.db.select().from(projectRevisions).all().length,

View file

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
applyExternalInitialImportSchema,
MAX_CSV_CONTENT_BASE64_LENGTH,
planExternalInitialImportSchema,
previewExternalCsvSchema,
updateExternalCsvConfigurationSchema,
@ -74,7 +75,7 @@ describe("external CSV API contracts", () => {
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "a".repeat(24_000_001),
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
}).success,
false
);

View file

@ -195,6 +195,30 @@ describe("external initial import project command", () => {
}
});
it("rejects a target state with zero classified objects before writing anything", () => {
// assertPopulatedInitialState requires at least one object, so a CSV
// that classified none must be rejected at command construction, not
// reach the persistence layer's bulk insert.
const { context, configuration } = createTestContext();
try {
const target: ExternalModelStateSnapshot = {
...initialState(configuration),
roomMappings: [],
objects: [],
};
assert.throws(
() =>
createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
/at least one object/
);
} finally {
context.close();
}
});
it("rejects changed state, checksum drift and row assignment", () => {
const { context, configuration } = createTestContext();
try {

83
tests/logger.test.ts Normal file
View file

@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createLogger, resolveLogLevel } from "../src/shared/logging/logger.js";
describe("resolveLogLevel", () => {
it("defaults to info for missing or unknown values", () => {
assert.equal(resolveLogLevel(undefined), "info");
assert.equal(resolveLogLevel(""), "info");
assert.equal(resolveLogLevel("nonsense"), "info");
});
it("accepts known levels case-insensitively", () => {
assert.equal(resolveLogLevel("DEBUG"), "debug");
assert.equal(resolveLogLevel(" verbose "), "verbose");
assert.equal(resolveLogLevel("warn"), "warn");
});
});
describe("createLogger", () => {
it("suppresses levels below the configured threshold", () => {
const infoLines: string[] = [];
const originalLog = console.log;
console.log = (line: string) => {
infoLines.push(line);
};
try {
const logger = createLogger("test", { level: "warn" });
logger.info("should not appear");
logger.debug("should not appear");
logger.verbose("should not appear");
} finally {
console.log = originalLog;
}
assert.equal(infoLines.length, 0);
});
it("emits enabled levels as structured JSON with scope and meta", () => {
const errorLines: string[] = [];
const originalError = console.error;
console.error = (line: string) => {
errorLines.push(line);
};
try {
const logger = createLogger("test", { level: "warn" });
logger.warn("something happened", { code: 42 });
} finally {
console.error = originalError;
}
assert.equal(errorLines.length, 1);
const parsed = JSON.parse(errorLines[0]);
assert.equal(parsed.level, "warn");
assert.equal(parsed.scope, "test");
assert.equal(parsed.message, "something happened");
assert.equal(parsed.code, 42);
assert.equal(typeof parsed.timestamp, "string");
});
it("routes debug/verbose/info to console.log and error/warn to console.error", () => {
const logLines: string[] = [];
const errorLines: string[] = [];
const originalLog = console.log;
const originalError = console.error;
console.log = (line: string) => {
logLines.push(line);
};
console.error = (line: string) => {
errorLines.push(line);
};
try {
const logger = createLogger("test", { level: "debug" });
logger.debug("d");
logger.verbose("v");
logger.info("i");
logger.warn("w");
logger.error("e");
} finally {
console.log = originalLog;
console.error = originalError;
}
assert.equal(logLines.length, 3);
assert.equal(errorLines.length, 2);
});
});

View file

@ -209,6 +209,13 @@ describe("circuit device-row update project commands", () => {
}),
/non-negative/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
});
});
@ -508,6 +515,14 @@ describe("circuit device-row structure project commands", () => {
}),
/must not be negative/
);
assert.throws(
() =>
createCircuitDeviceRowInsertProjectCommand({
...row,
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
assert.throws(
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
/rowId/

View file

@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
return row;
}
function linkExternalObject(
context: DatabaseContext,
rowId: string,
effectiveQuantity: number
) {
context.db
.insert(externalModelSources)
.values({
id: "source-1",
projectId: "project-1",
name: "Revit",
sourceType: "revit_csv",
})
.run();
context.db
.insert(externalImportBatches)
.values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
})
.run();
context.db
.insert(externalModelObjects)
.values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: "ifc-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: "101",
roomName: "Büro",
familyAndType: "Leuchte: Standard",
selectionMarker: "Leuchte",
circuitIdentifier: "-1F1",
power: "30",
quantity: String(effectiveQuantity),
additionalSourceValues: {},
},
planningValues: {
displayName: "Leuchte",
internalDeviceType: "luminaire",
category: "single_phase",
connectionKind: "fixed",
effectiveQuantity,
powerPerUnitW: 30,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: null,
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: rowId,
presenceStatus: "present",
})
.run();
}
function snapshot(
context: DatabaseContext,
rowId: string
@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => {
}
});
it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 5 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected,
target: { ...expected, quantity: 2 },
},
]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
} finally {
fixture.context.close();
}
});
it("subtracts linked external objects when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 4);
assert.equal(row.manualQuantity, 1);
} finally {
fixture.context.close();
}
});
it("rejects a synced quantity below the linked external total", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }]
),
}),
/below the total quantity of linked external objects/
);
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 5);
assert.equal(row.manualQuantity, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase();
try {

View file

@ -191,6 +191,32 @@ describe("project version history presentation", () => {
),
"Stromkreisgruppe vollständig wiederhergestellt"
);
assert.equal(
getProjectRevisionDescription(
revision(16, { commandType: "circuit-protection.update" })
),
"Stromkreisschutz bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(17, { commandType: "project-floor.update" })
),
"Geschoss bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(18, { commandType: "project-room.update" })
),
"Raum bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(19, {
commandType: "external-object.update-row-assignment",
})
),
"Externe Objektzuordnung geändert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),

View file

@ -11,5 +11,11 @@
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
"exclude": [
"node_modules",
"dist",
"src/proxy.ts",
"src/instrumentation.ts",
"src/instrumentation-node.ts"
]
}

View file

@ -18,6 +18,9 @@
"src/app/**/*.tsx",
"src/frontend/**/*.ts",
"src/frontend/**/*.tsx",
"src/proxy.ts",
"src/instrumentation.ts",
"src/instrumentation-node.ts",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "dist"]

View file

@ -5,5 +5,11 @@
"rootDir": "."
},
"include": ["scripts/**/*.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
"exclude": [
"node_modules",
"dist",
"src/proxy.ts",
"src/instrumentation.ts",
"src/instrumentation-node.ts"
]
}