Compare commits

..

No commits in common. "main" and "feature/amev-picker-and-collapsible-sidebar" have entirely different histories.

78 changed files with 722 additions and 5409 deletions

1
.gitignore vendored
View file

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

View file

@ -1,17 +1,10 @@
FROM node:24 FROM node:22
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . 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 npm run build:api && npm run build:web
RUN mkdir -p data && chmod +x scripts/docker-start.sh RUN mkdir -p data && chmod +x scripts/docker-start.sh

View file

@ -62,29 +62,15 @@ docker compose logs --follow
docker compose down docker compose down
``` ```
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in Produktionsdeployment. Details stehen in
[Deployment und Betrieb](docs/deployment.md). [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 ## Direkte lokale Entwicklung
Voraussetzungen: Voraussetzungen:
- Node.js 24 - Node.js 22
- npm - npm
```powershell ```powershell

View file

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

View file

@ -359,9 +359,8 @@ Response sketch:
### Circuit Structure ### Circuit Structure
- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier` - `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`) - 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 Circuit and device-row field updates, standalone insertions/deletions, single
or bulk device-row moves, circuit reorders and explicit renumbering are or bulk device-row moves, circuit reorders and explicit renumbering are

View file

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

View file

@ -2,27 +2,19 @@
## Aktueller Status ## Aktueller Status
Es gibt zwei Compose-Stacks. Es gibt derzeit kein unterstütztes Produktionsdeployment.
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und `compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack `tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
für einen Server. Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
öffentlich erreichbar gemacht werden.
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom ## Entwicklungs-Topologie
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 | | Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- | | --- | ---: | --- | --- |
| Next.js Web | 3001 | `GET /web-health` | keine | | Next.js Web | 3001 | `GET /` | keine |
| Express API | 3000 | `GET /health` | `./data:/app/data` | | Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
@ -32,56 +24,11 @@ Verwendete Umgebungsvariablen:
- `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz - `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000` `http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1` - `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` nur in - `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg Dateibeobachtung in Docker
- `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 die Migrationen und die Schemaprüfung Beim API-Start laufen zuerst `npm run db:migrate` und
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im `npm run db:verify:circuit-schema`.
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 ## Voraussetzungen für ein späteres Produktionssetup

View file

@ -1,385 +0,0 @@
"""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

@ -1,517 +0,0 @@
"""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(),
}

View file

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

23
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css"; import "./globals.css";
import { AppShell } from "../frontend/components/AppShell"; import { AppShell } from "../frontend/components/AppShell";
@ -9,33 +8,10 @@ export const metadata: Metadata = {
description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten", 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 }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="de" suppressHydrationWarning> <html lang="de">
<body> <body>
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</body> </body>
</html> </html>

View file

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

View file

@ -1,10 +0,0 @@
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

@ -1,2 +0,0 @@
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,13 +43,6 @@
"when": 1785687503453, "when": 1785687503453,
"tag": "0005_stale_gorilla_man", "tag": "0005_stale_gorilla_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
} }
] ]
} }

View file

@ -43,6 +43,12 @@ export interface CircuitDeviceRowPatchInput {
overriddenFields?: string | null; overriddenFields?: string | null;
} }
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
}
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) { export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
return { return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@ -99,3 +105,15 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
return values; 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 } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { import {
assertCircuitUpdateProjectCommand, assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
@ -21,7 +21,6 @@ import {
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
@ -167,12 +166,20 @@ export class CircuitProjectCommandRepository
) { ) {
return; return;
} }
assertEquipmentIdentifierAvailable( const duplicate = database
database, .select({ id: circuits.id })
circuit.circuitListId, .from(circuits)
equipmentIdentifier, .where(
circuit.id 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.");
}
} }
} }

View file

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

View file

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

View file

@ -1,41 +0,0 @@
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,9 +212,7 @@ function replaceExternalState(
if (target.roomMappings.length) { if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run(); database.insert(externalRoomMappings).values(target.roomMappings).run();
} }
if (target.objects.length) { database.insert(externalModelObjects).values(target.objects).run();
database.insert(externalModelObjects).values(target.objects).run();
}
} }
function decodeCanonicalBase64(value: string) { function decodeCanonicalBase64(value: string) {

View file

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

View file

@ -14,7 +14,6 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
@ -120,32 +119,9 @@ export class ProjectDeviceRowSyncProjectCommandRepository
); );
for (const assignment of input.command.payload.rows) { 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 const updated = tx
.update(circuitDeviceRows) .update(circuitDeviceRows)
.set(values) .set(assignment.target)
.where(eq(circuitDeviceRows.id, assignment.rowId)) .where(eq(circuitDeviceRows.id, assignment.rowId))
.run(); .run();
if (updated.changes !== 1) { if (updated.changes !== 1) {

View file

@ -1,38 +1,35 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js"; import { circuits } from "./circuits.js";
import { projectDevices } from "./project-devices.js"; import { projectDevices } from "./project-devices.js";
import { rooms } from "./rooms.js"; import { rooms } from "./rooms.js";
export const circuitDeviceRows = sqliteTable( export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
"circuit_device_rows", id: text("id").primaryKey(),
{ circuitId: text("circuit_id")
id: text("id").primaryKey(), .notNull()
circuitId: text("circuit_id") .references(() => circuits.id, { onDelete: "cascade" }),
.notNull() linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
.references(() => circuits.id, { onDelete: "cascade" }), onDelete: "set null",
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { }),
onDelete: "set null", sortOrder: integer("sort_order").notNull().default(0),
}), name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0), displayName: text("display_name").notNull(),
name: text("name").notNull(), phaseType: text("phase_type"),
displayName: text("display_name").notNull(), connectionKind: text("connection_kind"),
phaseType: text("phase_type"), costGroup: text("cost_group"),
connectionKind: text("connection_kind"), category: text("category"),
costGroup: text("cost_group"), level: text("level"),
category: text("category"), roomId: text("room_id").references(() => rooms.id, {
level: text("level"), onDelete: "set null",
roomId: text("room_id").references(() => rooms.id, { }),
onDelete: "set null", roomNumberSnapshot: text("room_number_snapshot"),
}), roomNameSnapshot: text("room_name_snapshot"),
roomNumberSnapshot: text("room_number_snapshot"), quantity: integer("quantity").notNull(),
roomNameSnapshot: text("room_name_snapshot"), manualQuantity: integer("manual_quantity").notNull().default(0),
quantity: integer("quantity").notNull(), powerPerUnit: real("power_per_unit").notNull(),
manualQuantity: integer("manual_quantity").notNull().default(0), simultaneityFactor: real("simultaneity_factor").notNull(),
powerPerUnit: real("power_per_unit").notNull(), cosPhi: real("cos_phi"),
simultaneityFactor: real("simultaneity_factor").notNull(), remark: text("remark"),
cosPhi: real("cos_phi"), overriddenFields: text("overridden_fields"),
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 { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js"; import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js"; import { circuitSections } from "./circuit-sections.js";
@ -26,9 +26,6 @@ export const circuits = sqliteTable(
isReserve: integer("is_reserve").notNull().default(0), isReserve: integer("is_reserve").notNull().default(0),
remark: text("remark"), remark: text("remark"),
}, },
(table) => [ (table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
index("circuits_section_id_idx").on(table.sectionId),
]
); );

View file

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

View file

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

View file

@ -0,0 +1,24 @@
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,33 +71,15 @@ export function assertCircuitProtectionUpdateProjectCommand(
if (target !== null) { if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId); assertCircuitProtectionSnapshot(target, circuitId);
} }
if (circuitProtectionSnapshotsEqual(expected, target)) { if (JSON.stringify(expected) === JSON.stringify(target)) {
throw new Error("Circuit protection update must change state."); 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( export function assertCircuitProtectionSnapshot(
value: unknown, value: unknown,
circuitId: string circuitId: string
): asserts value is CircuitProtectionSnapshot { ) {
if ( if (
!isPlainObject(value) || !isPlainObject(value) ||
Object.keys(value).length !== 7 || Object.keys(value).length !== 7 ||

View file

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

View file

@ -0,0 +1,19 @@
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(), quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(), manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1), simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(), cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema, remark: nullableStringSchema,
overriddenFields: nullableStringSchema, overriddenFields: nullableStringSchema,

View file

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

View file

@ -1,63 +0,0 @@
"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,10 +268,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [editingCell, setEditingCell] = useState<EditingCell | null>(null); const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null); const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); 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] = const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null); useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
@ -727,27 +723,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
); );
}, [data]); }, [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( const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [], () => data?.sections.flatMap((section) => section.circuits) ?? [],
[data] [data]
@ -1071,10 +1046,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Runs a normal command. The server records it in project-wide history. // Runs a normal command. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) { async function runCommand(command: HistoryCommand) {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try { try {
setError(null); setError(null);
setIsSaving(true); setIsSaving(true);
@ -1085,7 +1056,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false }); await loadTree({ showLoading: false });
setError(message); setError(message);
} finally { } finally {
commandInFlightRef.current = false;
setIsSaving(false); setIsSaving(false);
} }
} }
@ -1093,10 +1063,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Applies the next eligible project-wide history operation. Selection is only // Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned. // a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") { async function applyHistory(mode: "undo" | "redo") {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try { try {
setError(null); setError(null);
setHistoryBusy(true); setHistoryBusy(true);
@ -1122,7 +1088,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false }); await loadTree({ showLoading: false });
setError(message); setError(message);
} finally { } finally {
commandInFlightRef.current = false;
setIsSaving(false); setIsSaving(false);
setHistoryBusy(false); setHistoryBusy(false);
} }
@ -1779,7 +1744,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) { if (!section) {
throw new Error("Bereich wurde nicht gefunden."); throw new Error("Bereich wurde nicht gefunden.");
} }
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const isDeviceField = deviceFieldKeys.has(key); const isDeviceField = deviceFieldKeys.has(key);
@ -1968,7 +1933,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({ await runCommand({
label: "Stromkreis hinzufügen", label: "Stromkreis hinzufügen",
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const circuit = createCircuitSnapshot({ const circuit = createCircuitSnapshot({
sectionId, sectionId,
@ -2162,7 +2127,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) { if (!section) {
throw new Error("Der Zielbereich ist ungültig."); throw new Error("Der Zielbereich ist ungültig.");
} }
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const circuit = createCircuitSnapshot( const circuit = createCircuitSnapshot(
@ -2573,7 +2538,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({ await runCommand({
label: newCircuitLabel, label: newCircuitLabel,
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(projectId, intent.sectionId); const next = await getNextCircuitIdentifier(intent.sectionId);
const sortOrder = const sortOrder =
intent.targetCircuitId && intent.placement intent.targetCircuitId && intent.placement
? getAdjacentInsertionSortOrder( ? getAdjacentInsertionSortOrder(
@ -3734,7 +3699,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
!buildCircuitGroupReorderAssignments( !buildCircuitGroupReorderAssignments(
data.sections, data.sections,
section.id, section.id,
@ -3752,7 +3716,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
!buildCircuitGroupReorderAssignments( !buildCircuitGroupReorderAssignments(
data.sections, data.sections,
section.id, section.id,
@ -3770,7 +3733,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
hasActiveSortOrFilter || hasActiveSortOrFilter ||
!section.category || !section.category ||
!canRenumberCircuitGroups( !canRenumberCircuitGroups(
@ -3796,7 +3758,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
title={ title={
canDeleteCircuitGroup(section) canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen" ? "Leere Stromkreisgruppe entfernen"
@ -3854,18 +3815,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
Gruppen-FI hinzufügen Gruppen-FI hinzufügen
</button> </button>
<button <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen Stromkreis hinzufügen
</button> </button>
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={hasActiveSortOrFilter || isSaving} disabled={hasActiveSortOrFilter}
onClick={() => void handleRenumberSection(section.id)} onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined} title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
> >
@ -4470,7 +4426,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)} onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
> >
Manuelles Gerät hinzufügen Manuelles Gerät hinzufügen
@ -4478,7 +4433,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
onClick={() => void handleDeleteCircuit(row.circuit!.id)} onClick={() => void handleDeleteCircuit(row.circuit!.id)}
> >
Stromkreis löschen Stromkreis löschen
@ -4486,7 +4440,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}> <button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen Gerät löschen
</button> </button>
) : null} ) : null}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react"; import React, { type FormEvent, type ReactNode } from "react";
interface FormModalProps { interface FormModalProps {
children: ReactNode; children: ReactNode;
@ -14,9 +14,6 @@ interface FormModalProps {
title: string; 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({ export function FormModal({
children, children,
description, description,
@ -28,56 +25,11 @@ export function FormModal({
submitLabel, submitLabel,
title, title,
}: FormModalProps) { }: 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 ( return (
<> <>
<div <div
aria-modal="true" aria-modal="true"
className="modal fade show d-block" className="modal fade show d-block"
onKeyDown={handleKeyDown}
ref={dialogRef}
role="dialog" role="dialog"
tabIndex={-1} tabIndex={-1}
> >

View file

@ -7,7 +7,6 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board"; } from "../../shared/constants/distribution-board";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput { export interface ProjectSettingsInput {
name: string; name: string;
@ -132,278 +131,319 @@ export function ProjectSettingsModal({
} }
return ( return (
<FormModal <>
description="Stammdaten und elektrische Standardwerte des Projekts" <div
isSaving={isSaving} aria-labelledby="project-settings-title"
onClose={onClose} aria-modal="true"
onSubmit={handleSubmit} className="modal fade show d-block"
submitDisabled={!isValid} role="dialog"
submitLabel="Einstellungen speichern" tabIndex={-1}
title="Projekteinstellungen" >
> <div className="modal-dialog modal-lg modal-dialog-centered">
<div className="row g-3"> <form className="modal-content" onSubmit={handleSubmit}>
<div className="col-12"> <div className="modal-header">
<label className="form-label" htmlFor="project-name"> <div>
Projektname <h2 className="modal-title fs-5" id="project-settings-title">
</label> Projekteinstellungen
<input </h2>
autoFocus <p className="text-secondary small mb-0">
className="form-control" Stammdaten und elektrische Standardwerte des Projekts
id="project-name" </p>
maxLength={200} </div>
onChange={(event) => setName(event.target.value)} <button
required aria-label="Schließen"
value={name} className="btn-close"
/> disabled={isSaving}
</div> onClick={onClose}
<div className="col-12 col-md-6"> type="button"
<label className="form-label" htmlFor="internal-project-number"> />
Projektnummer intern </div>
</label> <div className="modal-body">
<input <div className="row g-3">
className="form-control" <div className="col-12">
id="internal-project-number" <label className="form-label" htmlFor="project-name">
maxLength={100} Projektname
onChange={(event) => </label>
setInternalProjectNumber(event.target.value) <input
} autoFocus
value={internalProjectNumber} className="form-control"
/> id="project-name"
</div> maxLength={200}
<div className="col-12 col-md-6"> onChange={(event) => setName(event.target.value)}
<label className="form-label" htmlFor="external-project-number"> required
Projektnummer extern value={name}
</label> />
<input </div>
className="form-control" <div className="col-12 col-md-6">
id="external-project-number" <label className="form-label" htmlFor="internal-project-number">
maxLength={100} Projektnummer intern
onChange={(event) => </label>
setExternalProjectNumber(event.target.value) <input
} className="form-control"
value={externalProjectNumber} id="internal-project-number"
/> maxLength={100}
</div> onChange={(event) =>
<div className="col-12"> setInternalProjectNumber(event.target.value)
<fieldset> }
<legend className="form-label mb-1"> value={internalProjectNumber}
Verwendete Netzarten />
</legend> </div>
<p className="form-text mt-0"> <div className="col-12 col-md-6">
Nur ausgewählte Netzarten stehen bei Verteilungen zur <label className="form-label" htmlFor="external-project-number">
Auswahl. Bereits verwendete Netzarten können nicht Projektnummer extern
deaktiviert werden. </label>
</p> <input
<div className="row g-2"> className="form-control"
{distributionBoardSupplyTypes.map((supplyType) => ( id="external-project-number"
<div maxLength={100}
className="col-12 col-md-6" onChange={(event) =>
key={supplyType} 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">
<label className="form-check"> <label className="form-check">
<input <input
checked={enabledDistributionBoardSupplyTypes.includes( checked={isPublicBuilding}
supplyType
)}
className="form-check-input" className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes( id="public-building"
supplyType onChange={(event) =>
)} setIsPublicBuilding(event.target.checked)
onChange={() => toggleSupplyType(supplyType)} }
type="checkbox" type="checkbox"
/> />
<span className="form-check-label"> <span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]} Öffentliches Gebäude
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span> </span>
</label> </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>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div> </div>
) : null} </div>
</fieldset> <div className="modal-footer">
</div> <button
<div className="col-12"> className="btn btn-outline-secondary"
<label className="form-label" htmlFor="building-owner"> disabled={isSaving}
Bauherr onClick={onClose}
</label> type="button"
<input >
className="form-control" Abbrechen
id="building-owner" </button>
maxLength={200} <button
onChange={(event) => setBuildingOwner(event.target.value)} className="btn btn-primary"
value={buildingOwner} disabled={isSaving || !isValid}
/> type="submit"
</div> >
<div className="col-12"> {isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
<label className="form-label" htmlFor="project-description"> </button>
Beschreibung </div>
</label> </form>
<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> </div>
</FormModal> <div className="modal-backdrop fade show" />
</>
); );
} }

View file

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

View file

@ -396,16 +396,6 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
if (trimmed === "") { if (trimmed === "") {
return undefined; 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(",", ".")); const parsed = Number(trimmed.replace(",", "."));
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`); throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);

View file

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

View file

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

View file

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

View file

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

View file

@ -47,24 +47,10 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt", "project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt", "project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt", "project-room.insert": "Raum angelegt",
"project-room.update": "Raum bearbeitet",
"project-room.delete": "Raum entfernt", "project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt", "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( export function getProjectRevisionSourceLabel(

View file

@ -1,27 +0,0 @@
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();
}

View file

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

View file

@ -1,24 +0,0 @@
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,21 +1,10 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js"; import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../composition/application-repositories.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) { export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { projectId, sectionId } = req.params; const { sectionId } = req.params;
if (typeof projectId !== "string" || typeof sectionId !== "string") { if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid parameters" }); return res.status(400).json({ error: "Invalid sectionId" });
}
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 { try {
const nextIdentifier = const nextIdentifier =

View file

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

View file

@ -1,79 +1,26 @@
import express from "express"; import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js"; import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
const logger = createLogger("api");
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const heartbeatIntervalMs = 5 * 60 * 1000;
app.use(express.json({ limit: "25mb" })); 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) => { app.get("/health", (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.use("/api/projects", projectRouter); app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api/global-devices", globalDeviceRouter); app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter); app.use("/api/project-devices", projectDeviceRouter);
app.use(errorMiddleware); app.use(errorMiddleware);
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, () => { app.listen(port, () => {
logger.info("server started", { port }); console.log(`Server running on http://localhost:${port}`);
}); });

View file

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

View file

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

View file

@ -1,77 +0,0 @@
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,10 +1,5 @@
import { z } from "zod"; 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 export const updateExternalCsvConfigurationSchema = z
.object({ .object({
expectedRevision: z.number().int().nonnegative(), expectedRevision: z.number().int().nonnegative(),
@ -15,7 +10,7 @@ export const updateExternalCsvConfigurationSchema = z
export const previewExternalCsvSchema = z export const previewExternalCsvSchema = z
.object({ .object({
fileName: z.string().trim().min(1).max(255), fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), contentBase64: z.string().min(1).max(24_000_000),
}) })
.strict(); .strict();
@ -26,7 +21,7 @@ export const applyExternalInitialImportSchema = z.object({
expectedConfigurationVersion: z.number().int().positive(), expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255), fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), contentBase64: z.string().min(1).max(24_000_000),
sourceName: z.string().trim().min(1).max(200), sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({ roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1), sourceRoomKey: z.string().trim().min(1),

View file

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

View file

@ -4,16 +4,16 @@ import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
import { circuitGroupCategories } from "../constants/circuit-group.js"; import { circuitGroupCategories } from "../constants/circuit-group.js";
export const createProjectDeviceSchema = z.object({ export const createProjectDeviceSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1),
displayName: z.string().min(1).max(200), displayName: z.string().min(1),
connectionKind: z.string().max(100).optional(), connectionKind: z.string().optional(),
costGroup: z.string().max(100).optional(), costGroup: z.string().optional(),
category: z.enum(circuitGroupCategories), category: z.enum(circuitGroupCategories),
quantity: z.number().min(0), quantity: z.number().min(0),
powerPerUnit: z.number().min(0), powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1), simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(), cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().max(2000).optional(), remark: z.string().optional(),
}).strict(); }).strict();
export const updateProjectDeviceSchema = createProjectDeviceSchema; export const updateProjectDeviceSchema = createProjectDeviceSchema;

View file

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

View file

@ -187,25 +187,12 @@ describe("circuit grid model", () => {
}); });
it("parses numeric drafts and rejects invalid values", () => { it("parses numeric drafts and rejects invalid values", () => {
assert.equal(parseNumeric("quantity", " 1500 "), 1500); assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25); assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
assert.equal(parseNumeric("quantity", ""), undefined); assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/); 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", () => { it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), { assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null, voltage: null,

View file

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

View file

@ -580,43 +580,4 @@ describe("circuit structure project-command repository", () => {
fixture.context.close(); 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

@ -1,100 +0,0 @@
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 snapshot
), ),
}), }),
/Duplicate equipmentIdentifier in circuit list\./ /UNIQUE constraint failed/
); );
assert.equal( assert.equal(
context.db.select().from(projectRevisions).all().length, context.db.select().from(projectRevisions).all().length,

View file

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

View file

@ -195,30 +195,6 @@ 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", () => { it("rejects changed state, checksum drift and row assignment", () => {
const { context, configuration } = createTestContext(); const { context, configuration } = createTestContext();
try { try {

View file

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

View file

@ -13,13 +13,9 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.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 { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import { import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot, type ProjectDeviceSyncRowSnapshot,
@ -167,79 +163,6 @@ function getRow(context: DatabaseContext, rowId: string) {
return row; 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( function snapshot(
context: DatabaseContext, context: DatabaseContext,
rowId: string rowId: string
@ -551,112 +474,6 @@ 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", () => { it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {

View file

@ -191,32 +191,6 @@ describe("project version history presentation", () => {
), ),
"Stromkreisgruppe vollständig wiederhergestellt" "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("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),

View file

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

View file

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

View file

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