forked from jappel/leistungsbilanz-ts
Compare commits
11 commits
feature/am
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 204499d7e4 | |||
| 906aa751c7 | |||
| c6cdfc42d5 | |||
| 9504905c8f | |||
| a17e2e3f4b | |||
| 64ccd1f829 | |||
| a99980c47b | |||
| b45dc5002d | |||
| fa96be2d42 | |||
| ea3c02cd6b | |||
|
|
f26c000007 |
78 changed files with 5410 additions and 723 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ dist/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
dynamo/output/
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
FROM node:22
|
FROM node:24
|
||||||
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
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -62,15 +62,29 @@ docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
||||||
Produktionsdeployment. Details stehen in
|
ohne Quellcode-Mounts und ohne Datei-Watcher. 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 22
|
- Node.js 24
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
87
compose.dev.yaml
Normal file
87
compose.dev.yaml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Development stack: source mounts, watching dev servers, hot reload.
|
||||||
|
# docker compose -f compose.dev.yaml up --build
|
||||||
|
#
|
||||||
|
# The polling watchers below are needed for bind mounts on Windows and
|
||||||
|
# macOS, where inotify events do not cross the VM boundary. They cost
|
||||||
|
# continuous CPU, which is why the production stack in compose.yaml does
|
||||||
|
# not run watchers at all.
|
||||||
|
name: leistungsbilanz-dev
|
||||||
|
|
||||||
|
x-logging: &logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./scripts:/app/scripts
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
command:
|
||||||
|
- npm
|
||||||
|
- run
|
||||||
|
- dev:web
|
||||||
|
- --
|
||||||
|
- --hostname
|
||||||
|
- 0.0.0.0
|
||||||
|
environment:
|
||||||
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
59
compose.yaml
59
compose.yaml
|
|
@ -1,68 +1,73 @@
|
||||||
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
|
||||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
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: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build: *build
|
||||||
context: .
|
|
||||||
command:
|
command:
|
||||||
- npm
|
- node_modules/.bin/next
|
||||||
- run
|
- start
|
||||||
- dev:web
|
- -p
|
||||||
- --
|
- "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/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
|
||||||
|
|
@ -359,8 +359,9 @@ Response sketch:
|
||||||
|
|
||||||
### Circuit Structure
|
### Circuit Structure
|
||||||
|
|
||||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
- `GET /projects/:projectId/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
|
||||||
|
|
|
||||||
|
|
@ -476,16 +476,28 @@ 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 bereits eine normalisierte,
|
Ein triggergeführtes Register erzwingt eine normalisierte,
|
||||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
|
Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites
|
||||||
Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
|
eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht
|
||||||
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
|
als denselben Wert. Die gemeinsame Prüfung
|
||||||
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
`src/db/repositories/equipment-identifier-uniqueness.persistence.ts`
|
||||||
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
|
schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem
|
||||||
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
|
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||||
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
|
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||||
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
|
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
||||||
|
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
|
||||||
|
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||||
|
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||||
|
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
||||||
|
an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte
|
||||||
|
Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite
|
||||||
|
Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes
|
||||||
|
Unterbaumlöschen und Stromkreisschutz sind integriert.
|
||||||
|
Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und
|
||||||
|
`circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten
|
||||||
|
Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und
|
||||||
|
Stromkreisen.
|
||||||
|
|
||||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,27 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## Aktueller Status
|
||||||
|
|
||||||
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
Es gibt zwei Compose-Stacks.
|
||||||
|
|
||||||
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
||||||
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
||||||
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
für einen Server.
|
||||||
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
|
||||||
öffentlich erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## Entwicklungs-Topologie
|
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
||||||
|
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
|
||||||
|
und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
||||||
|
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||||
|
Entwicklungsrechner.
|
||||||
|
|
||||||
|
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
||||||
|
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
||||||
|
erreichbar gemacht werden.
|
||||||
|
|
||||||
|
## Topologie
|
||||||
|
|
||||||
| Komponente | Port | Healthcheck | Persistenz |
|
| Komponente | Port | Healthcheck | Persistenz |
|
||||||
| --- | ---: | --- | --- |
|
| --- | ---: | --- | --- |
|
||||||
| Next.js Web | 3001 | `GET /` | keine |
|
| Next.js Web | 3001 | `GET /web-health` | keine |
|
||||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
| 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` |
|
||||||
|
|
||||||
|
|
@ -24,11 +32,56 @@ 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` für lokale
|
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
||||||
Dateibeobachtung in Docker
|
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
||||||
|
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||||
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
|
||||||
Beim API-Start laufen zuerst `npm run db:migrate` und
|
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
||||||
`npm run db:verify:circuit-schema`.
|
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
||||||
|
Entwicklungsstack über `npm run db:migrate` und
|
||||||
|
`npm run db:verify:circuit-schema`).
|
||||||
|
|
||||||
|
`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build`
|
||||||
|
schreibt die Rewrite-Ziele aus `next.config.mjs` fest in
|
||||||
|
`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als
|
||||||
|
Build-Argument an das Image durch, nicht nur als Laufzeit-Variable.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach
|
||||||
|
stdout/stderr (`docker compose logs --follow`). Jede Zeile enthält
|
||||||
|
`timestamp`, `level`, `scope` und `message`. `compose.yaml` konfiguriert für
|
||||||
|
beide Dienste den `json-file`-Treiber mit Rotation (`max-size: 20m`,
|
||||||
|
`max-file: 10`, also bis zu 200 MB je Dienst); ohne diese Einstellung würde
|
||||||
|
Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter
|
||||||
|
`/var/lib/docker/containers/<container-id>/` schreiben. Die Logs überleben
|
||||||
|
einen Container-Neustart (`docker compose restart`), aber nicht das Entfernen
|
||||||
|
des Containers (`docker compose down` gefolgt von `up` erzeugt neue
|
||||||
|
Container und damit neue, leere Logdateien); für ein echtes Langzeitarchiv
|
||||||
|
über Rebuilds hinweg müssten die Zeilen zusätzlich in eine Datei im
|
||||||
|
gemounteten `./data`-Verzeichnis oder an ein externes Log-System geschrieben
|
||||||
|
werden. Die Express-API protokolliert
|
||||||
|
jede abgeschlossene Anfrage (Methode, Pfad, Status, Dauer; `/health` wird
|
||||||
|
nicht mitgeloggt) sowie unbehandelte Exceptions/Promise-Rejections. Das
|
||||||
|
Next.js-Frontend protokolliert Seitenanfragen (Navigation) über
|
||||||
|
`src/proxy.ts` und unbehandelte Fehler über `src/instrumentation.ts`.
|
||||||
|
Beide Prozesse schreiben zusätzlich alle fünf Minuten einen `verbose`-Heartbeat
|
||||||
|
mit Laufzeit und Speicherverbrauch – nützlich, um Speicherlecks oder Hänger vor
|
||||||
|
einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche
|
||||||
|
`LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder
|
||||||
|
API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar.
|
||||||
|
Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before
|
||||||
|
response finished` (`warn`) geloggt und zeigt damit vom Client oder einem
|
||||||
|
vorgeschalteten Proxy abgebrochene Verbindungen.
|
||||||
|
|
||||||
|
Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet
|
||||||
|
den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in
|
||||||
|
einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
|
||||||
|
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
||||||
|
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
||||||
|
|
||||||
## Voraussetzungen für ein späteres Produktionssetup
|
## Voraussetzungen für ein späteres Produktionssetup
|
||||||
|
|
||||||
|
|
|
||||||
385
dynamo/01_check_model_identity.py
Normal file
385
dynamo/01_check_model_identity.py
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node.
|
||||||
|
|
||||||
|
Optional Dynamo input:
|
||||||
|
IN[0]: output directory or complete .json file path
|
||||||
|
|
||||||
|
The script intentionally performs no Revit transaction and changes no model
|
||||||
|
data. OUT contains a compact summary plus the complete report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import clr
|
||||||
|
|
||||||
|
clr.AddReference("RevitAPI")
|
||||||
|
clr.AddReference("RevitServices")
|
||||||
|
|
||||||
|
from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402
|
||||||
|
from RevitServices.Persistence import DocumentManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_text(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def element_id_text(element_id):
|
||||||
|
if element_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(element_id.Value)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return str(element_id.IntegerValue)
|
||||||
|
except Exception:
|
||||||
|
return safe_text(element_id)
|
||||||
|
|
||||||
|
|
||||||
|
def forge_type_id_text(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return value.TypeId
|
||||||
|
except Exception:
|
||||||
|
return safe_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parameter_value(parameter):
|
||||||
|
result = {
|
||||||
|
"hasValue": False,
|
||||||
|
"raw": None,
|
||||||
|
"display": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result["hasValue"] = bool(parameter.HasValue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_type = parameter.StorageType
|
||||||
|
if storage_type == StorageType.String:
|
||||||
|
result["raw"] = parameter.AsString()
|
||||||
|
elif storage_type == StorageType.Integer:
|
||||||
|
result["raw"] = int(parameter.AsInteger())
|
||||||
|
elif storage_type == StorageType.Double:
|
||||||
|
result["raw"] = float(parameter.AsDouble())
|
||||||
|
elif storage_type == StorageType.ElementId:
|
||||||
|
result["raw"] = element_id_text(parameter.AsElementId())
|
||||||
|
except Exception as error:
|
||||||
|
result["readError"] = safe_text(error)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["display"] = parameter.AsValueString()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def describe_parameter(parameter):
|
||||||
|
definition = None
|
||||||
|
try:
|
||||||
|
definition = parameter.Definition
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
name = None
|
||||||
|
if definition is not None:
|
||||||
|
try:
|
||||||
|
name = definition.Name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_shared = False
|
||||||
|
try:
|
||||||
|
is_shared = bool(parameter.IsShared)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
shared_guid = None
|
||||||
|
if is_shared:
|
||||||
|
try:
|
||||||
|
shared_guid = str(parameter.GUID)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data_type = None
|
||||||
|
group_type = None
|
||||||
|
if definition is not None:
|
||||||
|
try:
|
||||||
|
data_type = forge_type_id_text(definition.GetDataType())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
group_type = forge_type_id_text(definition.GetGroupTypeId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
unit_type = None
|
||||||
|
try:
|
||||||
|
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_type = str(parameter.StorageType)
|
||||||
|
except Exception:
|
||||||
|
storage_type = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_read_only = bool(parameter.IsReadOnly)
|
||||||
|
except Exception:
|
||||||
|
is_read_only = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_modifiable = bool(parameter.UserModifiable)
|
||||||
|
except Exception:
|
||||||
|
user_modifiable = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"parameterId": element_id_text(getattr(parameter, "Id", None)),
|
||||||
|
"isShared": is_shared,
|
||||||
|
"sharedGuid": shared_guid,
|
||||||
|
"storageType": storage_type,
|
||||||
|
"dataTypeId": data_type,
|
||||||
|
"groupTypeId": group_type,
|
||||||
|
"unitTypeId": unit_type,
|
||||||
|
"isReadOnly": is_read_only,
|
||||||
|
"userModifiable": user_modifiable,
|
||||||
|
"value": parameter_value(parameter),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_parameters(element):
|
||||||
|
parameters = []
|
||||||
|
try:
|
||||||
|
parameters = [describe_parameter(parameter) for parameter in element.Parameters]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
parameters,
|
||||||
|
key=lambda parameter: (
|
||||||
|
(parameter.get("name") or "").casefold(),
|
||||||
|
parameter.get("parameterId") or "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def named_parameter_occurrences(element, parameter_name):
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
parameters = element.GetParameters(parameter_name)
|
||||||
|
if parameters is not None:
|
||||||
|
result = [describe_parameter(parameter) for parameter in parameters]
|
||||||
|
except Exception:
|
||||||
|
parameter = None
|
||||||
|
try:
|
||||||
|
parameter = element.LookupParameter(parameter_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if parameter is not None:
|
||||||
|
result = [describe_parameter(parameter)]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_assembly_versions():
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
from System import AppDomain
|
||||||
|
|
||||||
|
for assembly in AppDomain.CurrentDomain.GetAssemblies():
|
||||||
|
try:
|
||||||
|
name = assembly.GetName()
|
||||||
|
simple_name = str(name.Name)
|
||||||
|
if simple_name in (
|
||||||
|
"DynamoCore",
|
||||||
|
"DynamoCoreWpf",
|
||||||
|
"DynamoRevitDS",
|
||||||
|
"RevitAPI",
|
||||||
|
"RevitServices",
|
||||||
|
):
|
||||||
|
result[simple_name] = str(name.Version)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return dict(sorted(result.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def get_cloud_identity(document):
|
||||||
|
result = {"isModelInCloud": False}
|
||||||
|
try:
|
||||||
|
result["isModelInCloud"] = bool(document.IsModelInCloud)
|
||||||
|
except Exception:
|
||||||
|
return result
|
||||||
|
if not result["isModelInCloud"]:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path = document.GetCloudModelPath()
|
||||||
|
result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
|
||||||
|
model_path
|
||||||
|
)
|
||||||
|
for property_name, output_name in (
|
||||||
|
("GetProjectGUID", "projectGuid"),
|
||||||
|
("GetModelGUID", "modelGuid"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result[output_name] = str(getattr(model_path, property_name)())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as error:
|
||||||
|
result["readError"] = safe_text(error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_worksharing_identity(document):
|
||||||
|
result = {"isWorkshared": False}
|
||||||
|
try:
|
||||||
|
result["isWorkshared"] = bool(document.IsWorkshared)
|
||||||
|
except Exception:
|
||||||
|
return result
|
||||||
|
if not result["isWorkshared"]:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path = document.GetWorksharingCentralModelPath()
|
||||||
|
result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
|
||||||
|
model_path
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
result["readError"] = safe_text(error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_path(configured_path, report_name):
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
|
||||||
|
raw_path = safe_text(configured_path)
|
||||||
|
if raw_path is None or not raw_path.strip():
|
||||||
|
directory = default_directory
|
||||||
|
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
|
||||||
|
else:
|
||||||
|
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
|
||||||
|
if expanded.lower().endswith(".json"):
|
||||||
|
file_path = expanded
|
||||||
|
directory = os.path.dirname(file_path)
|
||||||
|
else:
|
||||||
|
directory = expanded
|
||||||
|
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
|
||||||
|
if not directory:
|
||||||
|
directory = os.getcwd()
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
os.makedirs(directory)
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(file_path, payload):
|
||||||
|
temporary_path = file_path + ".tmp"
|
||||||
|
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
|
||||||
|
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
output.write("\n")
|
||||||
|
os.replace(temporary_path, file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input(index, default=None):
|
||||||
|
values = globals().get("IN", [])
|
||||||
|
try:
|
||||||
|
value = values[index]
|
||||||
|
return default if value is None else value
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def build_report():
|
||||||
|
document = DocumentManager.Instance.CurrentDBDocument
|
||||||
|
if document is None:
|
||||||
|
raise RuntimeError("No active Revit document is available.")
|
||||||
|
|
||||||
|
project_information = document.ProjectInformation
|
||||||
|
if project_information is None:
|
||||||
|
raise RuntimeError("The active document has no Project Information element.")
|
||||||
|
|
||||||
|
application = document.Application
|
||||||
|
checked_parameters = {
|
||||||
|
name: named_parameter_occurrences(project_information, name)
|
||||||
|
for name in CHECKED_PARAMETER_NAMES
|
||||||
|
}
|
||||||
|
warnings = []
|
||||||
|
for name in CHECKED_PARAMETER_NAMES:
|
||||||
|
occurrences = checked_parameters[name]
|
||||||
|
populated = [
|
||||||
|
parameter
|
||||||
|
for parameter in occurrences
|
||||||
|
if parameter.get("value", {}).get("raw") not in (None, "")
|
||||||
|
]
|
||||||
|
if not occurrences:
|
||||||
|
warnings.append(name + " is not bound to Project Information.")
|
||||||
|
elif not populated:
|
||||||
|
warnings.append(name + " exists but has no value on Project Information.")
|
||||||
|
elif len(occurrences) > 1:
|
||||||
|
warnings.append(name + " occurs more than once; use a shared-parameter GUID later.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reportSchemaVersion": 1,
|
||||||
|
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"readOnly": True,
|
||||||
|
"environment": {
|
||||||
|
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
|
||||||
|
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
|
||||||
|
"revitSubVersionNumber": safe_text(
|
||||||
|
getattr(application, "SubVersionNumber", None)
|
||||||
|
),
|
||||||
|
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
|
||||||
|
"pythonVersion": platform.python_version(),
|
||||||
|
"assemblies": loaded_assembly_versions(),
|
||||||
|
},
|
||||||
|
"document": {
|
||||||
|
"title": safe_text(document.Title),
|
||||||
|
"pathName": safe_text(document.PathName),
|
||||||
|
"isFamilyDocument": bool(document.IsFamilyDocument),
|
||||||
|
"cloud": get_cloud_identity(document),
|
||||||
|
"worksharing": get_worksharing_identity(document),
|
||||||
|
},
|
||||||
|
"modelIdentityCandidates": {
|
||||||
|
"projectInformationUniqueId": safe_text(project_information.UniqueId),
|
||||||
|
"projectInformationElementId": element_id_text(project_information.Id),
|
||||||
|
"checkedProjectParameters": checked_parameters,
|
||||||
|
},
|
||||||
|
"projectInformationParameters": sorted_parameters(project_information),
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = build_report()
|
||||||
|
output_path = resolve_output_path(get_input(0), "model-identity")
|
||||||
|
write_json(output_path, report)
|
||||||
|
OUT = {
|
||||||
|
"ok": True,
|
||||||
|
"filePath": output_path,
|
||||||
|
"projectInformationUniqueId": report["modelIdentityCandidates"][
|
||||||
|
"projectInformationUniqueId"
|
||||||
|
],
|
||||||
|
"warnings": report["warnings"],
|
||||||
|
"report": report,
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
OUT = {
|
||||||
|
"ok": False,
|
||||||
|
"error": safe_text(error),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal file
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
"""Export all Electrical Fixtures instance/type parameters from Revit 2026.
|
||||||
|
|
||||||
|
Optional Dynamo inputs:
|
||||||
|
IN[0]: output directory or complete .json file path
|
||||||
|
IN[1]: include empty parameters (default True)
|
||||||
|
IN[2]: maximum aggregated sample values (default 5)
|
||||||
|
|
||||||
|
The script is read-only and performs no Revit transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import clr
|
||||||
|
|
||||||
|
clr.AddReference("RevitAPI")
|
||||||
|
clr.AddReference("RevitServices")
|
||||||
|
|
||||||
|
from Autodesk.Revit.DB import ( # noqa: E402
|
||||||
|
BuiltInCategory,
|
||||||
|
FilteredElementCollector,
|
||||||
|
ModelPathUtils,
|
||||||
|
StorageType,
|
||||||
|
)
|
||||||
|
from RevitServices.Persistence import DocumentManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def safe_text(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def element_id_text(element_id):
|
||||||
|
if element_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(element_id.Value)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return str(element_id.IntegerValue)
|
||||||
|
except Exception:
|
||||||
|
return safe_text(element_id)
|
||||||
|
|
||||||
|
|
||||||
|
def forge_type_id_text(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return value.TypeId
|
||||||
|
except Exception:
|
||||||
|
return safe_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parameter_value(parameter):
|
||||||
|
result = {"hasValue": False, "raw": None, "display": None}
|
||||||
|
try:
|
||||||
|
result["hasValue"] = bool(parameter.HasValue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_type = parameter.StorageType
|
||||||
|
if storage_type == StorageType.String:
|
||||||
|
result["raw"] = parameter.AsString()
|
||||||
|
elif storage_type == StorageType.Integer:
|
||||||
|
result["raw"] = int(parameter.AsInteger())
|
||||||
|
elif storage_type == StorageType.Double:
|
||||||
|
result["raw"] = float(parameter.AsDouble())
|
||||||
|
elif storage_type == StorageType.ElementId:
|
||||||
|
result["raw"] = element_id_text(parameter.AsElementId())
|
||||||
|
except Exception as error:
|
||||||
|
result["readError"] = safe_text(error)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["display"] = parameter.AsValueString()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def describe_parameter(parameter, scope):
|
||||||
|
definition = None
|
||||||
|
try:
|
||||||
|
definition = parameter.Definition
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
name = None
|
||||||
|
data_type = None
|
||||||
|
group_type = None
|
||||||
|
if definition is not None:
|
||||||
|
try:
|
||||||
|
name = definition.Name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
data_type = forge_type_id_text(definition.GetDataType())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
group_type = forge_type_id_text(definition.GetGroupTypeId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_shared = False
|
||||||
|
try:
|
||||||
|
is_shared = bool(parameter.IsShared)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
shared_guid = None
|
||||||
|
if is_shared:
|
||||||
|
try:
|
||||||
|
shared_guid = str(parameter.GUID)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
unit_type = None
|
||||||
|
try:
|
||||||
|
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_type = str(parameter.StorageType)
|
||||||
|
except Exception:
|
||||||
|
storage_type = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_read_only = bool(parameter.IsReadOnly)
|
||||||
|
except Exception:
|
||||||
|
is_read_only = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_modifiable = bool(parameter.UserModifiable)
|
||||||
|
except Exception:
|
||||||
|
user_modifiable = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope": scope,
|
||||||
|
"name": name,
|
||||||
|
"parameterId": element_id_text(getattr(parameter, "Id", None)),
|
||||||
|
"isShared": is_shared,
|
||||||
|
"sharedGuid": shared_guid,
|
||||||
|
"storageType": storage_type,
|
||||||
|
"dataTypeId": data_type,
|
||||||
|
"groupTypeId": group_type,
|
||||||
|
"unitTypeId": unit_type,
|
||||||
|
"isReadOnly": is_read_only,
|
||||||
|
"userModifiable": user_modifiable,
|
||||||
|
"value": parameter_value(parameter),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_meaningful_value(parameter_description):
|
||||||
|
value = parameter_description.get("value", {})
|
||||||
|
return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get(
|
||||||
|
"display"
|
||||||
|
) not in (None, "")
|
||||||
|
|
||||||
|
|
||||||
|
def read_parameters(element, scope, include_empty):
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
for parameter in element.Parameters:
|
||||||
|
description = describe_parameter(parameter, scope)
|
||||||
|
if include_empty or has_meaningful_value(description):
|
||||||
|
result.append(description)
|
||||||
|
except Exception as error:
|
||||||
|
return [], [safe_text(error)]
|
||||||
|
result.sort(
|
||||||
|
key=lambda parameter: (
|
||||||
|
(parameter.get("name") or "").casefold(),
|
||||||
|
parameter.get("parameterId") or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result, []
|
||||||
|
|
||||||
|
|
||||||
|
def read_space(element, document):
|
||||||
|
try:
|
||||||
|
space = element.Space
|
||||||
|
except Exception as error:
|
||||||
|
return None, safe_text(error)
|
||||||
|
if space is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
level_name = None
|
||||||
|
try:
|
||||||
|
level = document.GetElement(space.LevelId)
|
||||||
|
level_name = None if level is None else safe_text(level.Name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"uniqueId": safe_text(space.UniqueId),
|
||||||
|
"elementId": element_id_text(space.Id),
|
||||||
|
"number": safe_text(getattr(space, "Number", None)),
|
||||||
|
"name": safe_text(getattr(space, "Name", None)),
|
||||||
|
"levelName": level_name,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def read_family_identity(element, document):
|
||||||
|
symbol = None
|
||||||
|
try:
|
||||||
|
symbol = element.Symbol
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
symbol = document.GetElement(element.GetTypeId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
family_name = None
|
||||||
|
type_name = None
|
||||||
|
type_unique_id = None
|
||||||
|
type_element_id = None
|
||||||
|
if symbol is not None:
|
||||||
|
try:
|
||||||
|
family_name = safe_text(symbol.Family.Name)
|
||||||
|
except Exception:
|
||||||
|
family_name = safe_text(getattr(symbol, "FamilyName", None))
|
||||||
|
type_name = safe_text(getattr(symbol, "Name", None))
|
||||||
|
type_unique_id = safe_text(getattr(symbol, "UniqueId", None))
|
||||||
|
type_element_id = element_id_text(getattr(symbol, "Id", None))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"familyName": family_name,
|
||||||
|
"typeName": type_name,
|
||||||
|
"typeUniqueId": type_unique_id,
|
||||||
|
"typeElementId": type_element_id,
|
||||||
|
}, symbol
|
||||||
|
|
||||||
|
|
||||||
|
def parameter_inventory_key(parameter):
|
||||||
|
stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or ""
|
||||||
|
return "|".join(
|
||||||
|
(
|
||||||
|
parameter.get("scope") or "",
|
||||||
|
stable_id,
|
||||||
|
parameter.get("name") or "",
|
||||||
|
parameter.get("dataTypeId") or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_value_key(value):
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_inventory(inventory, parameter, max_samples):
|
||||||
|
key = parameter_inventory_key(parameter)
|
||||||
|
entry = inventory.get(key)
|
||||||
|
if entry is None:
|
||||||
|
entry = {
|
||||||
|
"scope": parameter.get("scope"),
|
||||||
|
"name": parameter.get("name"),
|
||||||
|
"parameterId": parameter.get("parameterId"),
|
||||||
|
"isShared": parameter.get("isShared"),
|
||||||
|
"sharedGuid": parameter.get("sharedGuid"),
|
||||||
|
"storageType": parameter.get("storageType"),
|
||||||
|
"dataTypeId": parameter.get("dataTypeId"),
|
||||||
|
"groupTypeId": parameter.get("groupTypeId"),
|
||||||
|
"unitTypeId": parameter.get("unitTypeId"),
|
||||||
|
"occurrenceCount": 0,
|
||||||
|
"populatedCount": 0,
|
||||||
|
"sampleValues": [],
|
||||||
|
"_sampleKeys": set(),
|
||||||
|
}
|
||||||
|
inventory[key] = entry
|
||||||
|
entry["occurrenceCount"] += 1
|
||||||
|
if has_meaningful_value(parameter):
|
||||||
|
entry["populatedCount"] += 1
|
||||||
|
value = parameter.get("value")
|
||||||
|
value_key = sample_value_key(value)
|
||||||
|
if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]:
|
||||||
|
entry["_sampleKeys"].add(value_key)
|
||||||
|
entry["sampleValues"].append(value)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_inventory(inventory):
|
||||||
|
result = []
|
||||||
|
for entry in inventory.values():
|
||||||
|
clean_entry = dict(entry)
|
||||||
|
clean_entry.pop("_sampleKeys", None)
|
||||||
|
result.append(clean_entry)
|
||||||
|
return sorted(
|
||||||
|
result,
|
||||||
|
key=lambda entry: (
|
||||||
|
entry.get("scope") or "",
|
||||||
|
(entry.get("name") or "").casefold(),
|
||||||
|
entry.get("sharedGuid") or entry.get("parameterId") or "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_assembly_versions():
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
from System import AppDomain
|
||||||
|
|
||||||
|
for assembly in AppDomain.CurrentDomain.GetAssemblies():
|
||||||
|
try:
|
||||||
|
name = assembly.GetName()
|
||||||
|
simple_name = str(name.Name)
|
||||||
|
if simple_name in (
|
||||||
|
"DynamoCore",
|
||||||
|
"DynamoCoreWpf",
|
||||||
|
"DynamoRevitDS",
|
||||||
|
"RevitAPI",
|
||||||
|
"RevitServices",
|
||||||
|
):
|
||||||
|
result[simple_name] = str(name.Version)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return dict(sorted(result.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_path(configured_path):
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
|
||||||
|
raw_path = safe_text(configured_path)
|
||||||
|
if raw_path is None or not raw_path.strip():
|
||||||
|
directory = default_directory
|
||||||
|
file_path = os.path.join(
|
||||||
|
directory, "electrical-fixture-parameters-" + timestamp + ".json"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
|
||||||
|
if expanded.lower().endswith(".json"):
|
||||||
|
file_path = expanded
|
||||||
|
directory = os.path.dirname(file_path)
|
||||||
|
else:
|
||||||
|
directory = expanded
|
||||||
|
file_path = os.path.join(
|
||||||
|
directory, "electrical-fixture-parameters-" + timestamp + ".json"
|
||||||
|
)
|
||||||
|
if not directory:
|
||||||
|
directory = os.getcwd()
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
os.makedirs(directory)
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(file_path, payload):
|
||||||
|
temporary_path = file_path + ".tmp"
|
||||||
|
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
|
||||||
|
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
output.write("\n")
|
||||||
|
os.replace(temporary_path, file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input(index, default=None):
|
||||||
|
values = globals().get("IN", [])
|
||||||
|
try:
|
||||||
|
value = values[index]
|
||||||
|
return default if value is None else value
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(include_empty, max_samples):
|
||||||
|
document = DocumentManager.Instance.CurrentDBDocument
|
||||||
|
if document is None:
|
||||||
|
raise RuntimeError("No active Revit document is available.")
|
||||||
|
if document.IsFamilyDocument:
|
||||||
|
raise RuntimeError("Open a Revit project document, not a family document.")
|
||||||
|
|
||||||
|
application = document.Application
|
||||||
|
collector = (
|
||||||
|
FilteredElementCollector(document)
|
||||||
|
.OfCategory(BuiltInCategory.OST_ElectricalFixtures)
|
||||||
|
.WhereElementIsNotElementType()
|
||||||
|
)
|
||||||
|
source_elements = list(collector)
|
||||||
|
source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "")
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
types_by_unique_id = {}
|
||||||
|
inventory = {}
|
||||||
|
errors = []
|
||||||
|
elements_without_space = 0
|
||||||
|
|
||||||
|
for element in source_elements:
|
||||||
|
element_errors = []
|
||||||
|
try:
|
||||||
|
family_identity, symbol = read_family_identity(element, document)
|
||||||
|
instance_parameters, parameter_errors = read_parameters(
|
||||||
|
element, "instance", include_empty
|
||||||
|
)
|
||||||
|
element_errors.extend(parameter_errors)
|
||||||
|
for parameter in instance_parameters:
|
||||||
|
add_to_inventory(inventory, parameter, max_samples)
|
||||||
|
|
||||||
|
type_unique_id = family_identity.get("typeUniqueId")
|
||||||
|
if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id:
|
||||||
|
type_parameters, type_errors = read_parameters(symbol, "type", include_empty)
|
||||||
|
element_errors.extend(type_errors)
|
||||||
|
for parameter in type_parameters:
|
||||||
|
add_to_inventory(inventory, parameter, max_samples)
|
||||||
|
types_by_unique_id[type_unique_id] = {
|
||||||
|
**family_identity,
|
||||||
|
"parameters": type_parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
space, space_error = read_space(element, document)
|
||||||
|
if space_error:
|
||||||
|
element_errors.append("MEP Space: " + space_error)
|
||||||
|
if space is None:
|
||||||
|
elements_without_space += 1
|
||||||
|
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"uniqueId": safe_text(element.UniqueId),
|
||||||
|
"elementId": element_id_text(element.Id),
|
||||||
|
"categoryName": safe_text(
|
||||||
|
None if element.Category is None else element.Category.Name
|
||||||
|
),
|
||||||
|
"family": family_identity,
|
||||||
|
"space": space,
|
||||||
|
"instanceParameters": instance_parameters,
|
||||||
|
"warnings": element_errors,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"uniqueId": safe_text(getattr(element, "UniqueId", None)),
|
||||||
|
"elementId": element_id_text(getattr(element, "Id", None)),
|
||||||
|
"error": safe_text(error),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reportSchemaVersion": 1,
|
||||||
|
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"readOnly": True,
|
||||||
|
"complete": len(errors) == 0,
|
||||||
|
"scope": {
|
||||||
|
"builtInCategory": "OST_ElectricalFixtures",
|
||||||
|
"wholeDocument": True,
|
||||||
|
"elementTypesExcluded": True,
|
||||||
|
"includeEmptyParameters": include_empty,
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
|
||||||
|
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
|
||||||
|
"revitSubVersionNumber": safe_text(
|
||||||
|
getattr(application, "SubVersionNumber", None)
|
||||||
|
),
|
||||||
|
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
|
||||||
|
"pythonVersion": platform.python_version(),
|
||||||
|
"assemblies": loaded_assembly_versions(),
|
||||||
|
},
|
||||||
|
"document": {
|
||||||
|
"title": safe_text(document.Title),
|
||||||
|
"pathName": safe_text(document.PathName),
|
||||||
|
"projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId),
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"elementCount": len(source_elements),
|
||||||
|
"exportedElementCount": len(elements),
|
||||||
|
"typeCount": len(types_by_unique_id),
|
||||||
|
"parameterDefinitionCount": len(inventory),
|
||||||
|
"elementsWithoutMepSpace": elements_without_space,
|
||||||
|
"elementErrorCount": len(errors),
|
||||||
|
},
|
||||||
|
"parameterInventory": finalize_inventory(inventory),
|
||||||
|
"types": sorted(
|
||||||
|
types_by_unique_id.values(),
|
||||||
|
key=lambda entry: (
|
||||||
|
(entry.get("familyName") or "").casefold(),
|
||||||
|
(entry.get("typeName") or "").casefold(),
|
||||||
|
entry.get("typeUniqueId") or "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"elements": elements,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
include_empty_input = get_input(1, True)
|
||||||
|
include_empty = bool(include_empty_input)
|
||||||
|
try:
|
||||||
|
max_samples = int(get_input(2, 5))
|
||||||
|
except Exception:
|
||||||
|
max_samples = 5
|
||||||
|
max_samples = max(0, min(max_samples, 50))
|
||||||
|
|
||||||
|
report = build_report(include_empty, max_samples)
|
||||||
|
output_path = resolve_output_path(get_input(0))
|
||||||
|
write_json(output_path, report)
|
||||||
|
OUT = {
|
||||||
|
"ok": True,
|
||||||
|
"filePath": output_path,
|
||||||
|
"complete": report["complete"],
|
||||||
|
"summary": report["summary"],
|
||||||
|
"errors": report["errors"],
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
OUT = {
|
||||||
|
"ok": False,
|
||||||
|
"error": safe_text(error),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
69
dynamo/README.md
Normal file
69
dynamo/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Revit 2026 / Dynamo diagnostics
|
||||||
|
|
||||||
|
This directory contains self-contained Python scripts for a Dynamo **Python
|
||||||
|
Script** node. They use only Dynamo's built-in Revit integration, the Revit API
|
||||||
|
and the Python standard library. No Dynamo package is required.
|
||||||
|
|
||||||
|
The scripts are read-only. They do not start a Revit transaction and do not
|
||||||
|
change the open model.
|
||||||
|
|
||||||
|
## Python engine
|
||||||
|
|
||||||
|
Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with
|
||||||
|
Revit; optional PythonNet3 packages are not required by these diagnostics.
|
||||||
|
|
||||||
|
## 01 - Check model identity
|
||||||
|
|
||||||
|
File: `01_check_model_identity.py`
|
||||||
|
|
||||||
|
The script reports:
|
||||||
|
|
||||||
|
- Revit, Dynamo and Python versions;
|
||||||
|
- `ProjectInformation.UniqueId` as a native model-identity candidate;
|
||||||
|
- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project
|
||||||
|
Information;
|
||||||
|
- all Project Information parameters;
|
||||||
|
- optional cloud/worksharing identity information when the API exposes it.
|
||||||
|
|
||||||
|
Input `IN[0]` is optional. It may be either an output directory or a complete
|
||||||
|
`.json` file path. With no input, the report is written below the current
|
||||||
|
Windows temporary directory in `leistungsbilanz-dynamo`.
|
||||||
|
|
||||||
|
## 02 - Inventory Electrical Fixtures parameters
|
||||||
|
|
||||||
|
File: `02_export_electrical_fixture_parameter_inventory.py`
|
||||||
|
|
||||||
|
The script reads every instance of
|
||||||
|
`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It
|
||||||
|
exports:
|
||||||
|
|
||||||
|
- element, family, type and MEP Space identities;
|
||||||
|
- every instance parameter and value;
|
||||||
|
- every unique family-type parameter and value;
|
||||||
|
- an aggregated parameter inventory with occurrence counts and sample values;
|
||||||
|
- per-element warnings instead of aborting at the first unreadable element.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- `IN[0]` (optional): output directory or complete `.json` path;
|
||||||
|
- `IN[1]` (optional): include empty parameters, default `true`;
|
||||||
|
- `IN[2]` (optional): maximum sample values per aggregated parameter, default
|
||||||
|
`5`.
|
||||||
|
|
||||||
|
The default output location is again the Windows temporary directory. The
|
||||||
|
generated report can contain model paths and project-specific parameter values;
|
||||||
|
review it before sharing or committing it.
|
||||||
|
|
||||||
|
## Running a script
|
||||||
|
|
||||||
|
1. Open the target model in Revit 2026.
|
||||||
|
2. Open Dynamo from **Manage > Visual Programming > Dynamo**.
|
||||||
|
3. Create a graph and add a **Python Script** node.
|
||||||
|
4. Select the `CPython3` engine for the node.
|
||||||
|
5. Copy the complete content of the desired `.py` file into the node.
|
||||||
|
6. Optionally connect a String node containing the output path to `IN[0]`.
|
||||||
|
7. Run the graph and inspect `OUT` for status, counts and the generated path.
|
||||||
|
|
||||||
|
For the first test, run `01_check_model_identity.py` in the Revit main model.
|
||||||
|
Then run the parameter inventory. Keep both generated JSON files so their
|
||||||
|
structure can be checked before the production snapshot DTO is finalized.
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
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",
|
||||||
},
|
},
|
||||||
|
|
@ -20,3 +26,4 @@ const nextConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
||||||
|
|
|
||||||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -22,12 +22,15 @@
|
||||||
"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": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@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": {
|
||||||
|
|
@ -1514,12 +1517,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
|
|
@ -3688,10 +3692,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
"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",
|
||||||
|
|
@ -10,6 +13,9 @@
|
||||||
"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",
|
||||||
|
|
@ -40,7 +46,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": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@
|
||||||
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 */
|
||||||
|
|
@ -33,6 +39,105 @@
|
||||||
--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 {
|
||||||
|
|
@ -109,6 +214,12 @@ 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 {
|
||||||
|
|
@ -165,6 +276,7 @@ a {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
|
|
@ -210,6 +322,32 @@ 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;
|
||||||
|
|
@ -230,6 +368,10 @@ 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;
|
||||||
|
|
@ -262,7 +404,7 @@ a {
|
||||||
a.kpi:hover {
|
a.kpi:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
|
box-shadow: 0 3px 8px var(--shadow-soft), 0 16px 32px -14px var(--shadow-strong);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,12 +427,16 @@ 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 rgba(15, 39, 51, 0.05),
|
0 1px 2px var(--shadow-soft),
|
||||||
0 10px 24px -14px rgba(15, 39, 51, 0.22);
|
0 10px 24px -14px var(--shadow-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
|
|
@ -300,6 +446,10 @@ 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;
|
||||||
|
|
@ -320,13 +470,14 @@ a.kpi:hover {
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
box-shadow: 0 1px 0 rgba(196, 205, 220, 0.8);
|
box-shadow: 0 1px 0 var(--panel-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button {
|
.editor-toolbar button {
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -337,8 +488,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar .project-device-drawer-toggle {
|
.editor-toolbar .project-device-drawer-toggle {
|
||||||
border-color: #2563eb;
|
border-color: var(--accent-blue);
|
||||||
background: #2563eb;
|
background: var(--accent-blue);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
@ -349,10 +500,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 #bfdbfe;
|
border: 1px solid var(--accent-blue-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #eff6ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
color: #1e3a5f;
|
color: var(--text-subtle);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,10 +513,10 @@ a.kpi:hover {
|
||||||
|
|
||||||
.active-view-chip,
|
.active-view-chip,
|
||||||
.active-view-reset {
|
.active-view-reset {
|
||||||
border: 1px solid #93b4df;
|
border: 1px solid var(--accent-blue-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
color: #1e3a5f;
|
color: var(--text-subtle);
|
||||||
padding: 0.18rem 0.48rem;
|
padding: 0.18rem 0.48rem;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -373,7 +524,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.active-view-chip:hover,
|
.active-view-chip:hover,
|
||||||
.active-view-reset:hover {
|
.active-view-reset:hover {
|
||||||
border-color: #2563eb;
|
border-color: var(--accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-view-reset {
|
.active-view-reset {
|
||||||
|
|
@ -386,9 +537,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 #cbd5e1;
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #f8fafc;
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.distribution-power-summary > div {
|
.distribution-power-summary > div {
|
||||||
|
|
@ -398,13 +549,13 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.distribution-power-summary span {
|
.distribution-power-summary span {
|
||||||
color: #475569;
|
color: var(--text-subtle);
|
||||||
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: #172033;
|
color: var(--text-strong);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,13 +569,13 @@ a.kpi:hover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border: 1px solid #cfd7e5;
|
border: 1px solid var(--panel-border);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 6px 16px var(--shadow-menu);
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
color: #1f2937;
|
color: var(--text-strong);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +595,7 @@ a.kpi:hover {
|
||||||
.column-settings-close {
|
.column-settings-close {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
color: #4b5563;
|
color: var(--text-muted);
|
||||||
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;
|
||||||
|
|
@ -453,7 +604,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.column-settings-explanation {
|
.column-settings-explanation {
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
color: #4b5563;
|
color: var(--text-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
@ -461,9 +612,11 @@ a.kpi:hover {
|
||||||
.column-settings-search {
|
.column-settings-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,7 +626,7 @@ a.kpi:hover {
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #e1e6ef;
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
@ -489,8 +642,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-item.selected {
|
.column-settings-item.selected {
|
||||||
border-color: #bfdbfe;
|
border-color: var(--accent-blue-border);
|
||||||
background: #eff6ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-item.dragging {
|
.column-settings-item.dragging {
|
||||||
|
|
@ -498,12 +651,12 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-item.drop-target {
|
.column-settings-item.drop-target {
|
||||||
border-color: #2b6cb0;
|
border-color: var(--accent-blue-drop-border);
|
||||||
background: #ebf4ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-item.locked {
|
.column-settings-item.locked {
|
||||||
background: #f7fafc;
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-visibility-button {
|
.column-visibility-button {
|
||||||
|
|
@ -530,7 +683,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: #1d4ed8;
|
color: var(--accent-blue-marker);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,8 +693,9 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-order button {
|
.column-settings-order button {
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -549,8 +703,8 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid-wrap {
|
.tree-grid-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d9dee8;
|
border: 1px solid var(--panel-border);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -560,7 +714,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.column-settings-empty {
|
.column-settings-empty {
|
||||||
padding: 0.55rem 0.35rem;
|
padding: 0.55rem 0.35rem;
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,8 +724,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-settings-footer button.primary {
|
.column-settings-footer button.primary {
|
||||||
border-color: #2563eb;
|
border-color: var(--accent-blue);
|
||||||
background: #2563eb;
|
background: var(--accent-blue);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,8 +737,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-sidebar {
|
.project-device-sidebar {
|
||||||
border: 1px solid #d9dee8;
|
border: 1px solid var(--panel-border);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -594,14 +748,17 @@ 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 #cfd7e5;
|
border: 1px solid var(--panel-border-strong);
|
||||||
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 {
|
||||||
|
|
@ -613,8 +770,9 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-item {
|
.project-device-item {
|
||||||
border: 1px solid #d5ddec;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #f8faff;
|
background: var(--panel-bg-subtle);
|
||||||
|
color: var(--text-strong);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
@ -625,8 +783,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-item.selected {
|
.project-device-item.selected {
|
||||||
border-color: #4c7dd9;
|
border-color: var(--accent-blue-strong-border);
|
||||||
background: #edf3ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-item.dragging {
|
.project-device-item.dragging {
|
||||||
|
|
@ -645,11 +803,13 @@ 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 #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -660,18 +820,19 @@ 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 #e4e9f2;
|
border: 1px solid var(--panel-border);
|
||||||
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: #f4f7fb;
|
background: var(--panel-bg-subtle);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
@ -694,8 +855,9 @@ a.kpi:hover {
|
||||||
.tree-grid .header-filter-btn {
|
.tree-grid .header-filter-btn {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -712,7 +874,7 @@ a.kpi:hover {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: #1f2937;
|
color: var(--text-strong);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -733,7 +895,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 rgba(31, 41, 55, 0.22);
|
box-shadow: 0 0.75rem 2rem var(--shadow-drawer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-drawer-header {
|
.project-device-drawer-header {
|
||||||
|
|
@ -744,13 +906,14 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-drawer-header span {
|
.project-device-drawer-header span {
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-drawer-header button {
|
.project-device-drawer-header button {
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -762,23 +925,24 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .sort-indicator {
|
.tree-grid .sort-indicator {
|
||||||
color: #2563eb;
|
color: var(--accent-blue);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-btn.active {
|
.tree-grid .header-filter-btn.active {
|
||||||
border-color: #2563eb;
|
border-color: var(--accent-blue);
|
||||||
color: #2563eb;
|
color: var(--accent-blue);
|
||||||
background: #eff6ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 #cfd7e5;
|
border: 1px solid var(--panel-border);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
color: var(--text-strong);
|
||||||
|
box-shadow: 0 6px 16px var(--shadow-menu);
|
||||||
width: 300px;
|
width: 300px;
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -802,7 +966,7 @@ a.kpi:hover {
|
||||||
.tree-grid .header-filter-close {
|
.tree-grid .header-filter-close {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #4b5563;
|
color: var(--text-muted);
|
||||||
padding: 0.05rem 0.2rem;
|
padding: 0.05rem 0.2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -817,13 +981,14 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .header-filter-selection-actions span {
|
.tree-grid .header-filter-selection-actions span {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #4b5563;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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;
|
||||||
|
|
@ -832,7 +997,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .header-filter-explanation {
|
.tree-grid .header-filter-explanation {
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
color: #4b5563;
|
color: var(--text-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
@ -840,9 +1005,11 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .header-filter-search {
|
.tree-grid .header-filter-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -852,7 +1019,7 @@ a.kpi:hover {
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
max-height: 210px;
|
max-height: 210px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #e1e6ef;
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
@ -866,7 +1033,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: #1f2937;
|
color: var(--text-strong);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -874,17 +1041,17 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-item:hover {
|
.tree-grid .header-filter-item:hover {
|
||||||
background: #f3f4f6;
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-item.selected {
|
.tree-grid .header-filter-item.selected {
|
||||||
border-color: #bfdbfe;
|
border-color: var(--accent-blue-border);
|
||||||
background: #eff6ff;
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-empty {
|
.tree-grid .header-filter-empty {
|
||||||
padding: 0.45rem 0.25rem;
|
padding: 0.45rem 0.25rem;
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -897,8 +1064,8 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-footer button.primary {
|
.tree-grid .header-filter-footer button.primary {
|
||||||
border-color: #2563eb;
|
border-color: var(--accent-blue);
|
||||||
background: #2563eb;
|
background: var(--accent-blue);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -908,7 +1075,7 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-warning {
|
.tree-grid .header-filter-warning {
|
||||||
color: #9a3412;
|
color: var(--grid-warn-strong);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -918,25 +1085,25 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .section-row td {
|
.tree-grid .section-row td {
|
||||||
background: #e8eef8;
|
background: var(--surface-header-row);
|
||||||
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: #d8dee9;
|
border-bottom-color: var(--panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-row.headerComponent td {
|
.tree-grid .structure-component-row.headerComponent td {
|
||||||
background: #e2e8f0;
|
background: var(--surface-component-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-row.groupComponent td {
|
.tree-grid .structure-component-row.groupComponent td {
|
||||||
background: #f8fafc;
|
background: var(--surface-component-group);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-row.footerComponent td {
|
.tree-grid .structure-component-row.footerComponent td {
|
||||||
background: #f1f5f9;
|
background: var(--surface-component-footer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-content {
|
.tree-grid .structure-component-content {
|
||||||
|
|
@ -952,7 +1119,7 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-protection {
|
.tree-grid .structure-component-protection {
|
||||||
color: #475569;
|
color: var(--text-subtle);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -969,7 +1136,7 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .structure-component-fixed {
|
.tree-grid .structure-component-fixed {
|
||||||
color: #64748b;
|
color: var(--text-faint);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,32 +1157,33 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .section-actions button {
|
.tree-grid .section-actions button {
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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: #f3f7fd;
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .device-row td:first-child {
|
.tree-grid .device-row td:first-child {
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .empty-circuit-row td {
|
.tree-grid .empty-circuit-row td {
|
||||||
background: #f8fbff;
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid tr.row-selected td {
|
.tree-grid tr.row-selected td {
|
||||||
background: #eaf1ff;
|
background: var(--surface-selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .placeholder-row td {
|
.tree-grid .placeholder-row td {
|
||||||
background: #f7f7f7;
|
background: var(--panel-bg-subtle);
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1032,8 +1200,12 @@ 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: #eaf6f0;
|
background: rgba(63, 166, 107, 0.15);
|
||||||
box-shadow: inset 0 0 0 1px var(--color-signal);
|
box-shadow: inset 0 0 0 1px var(--color-signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1054,7 +1226,7 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .cell-selected {
|
.tree-grid .cell-selected {
|
||||||
outline: 2px solid #4c7dd9;
|
outline: 2px solid var(--accent-blue-strong-border);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1065,28 +1237,30 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .section-title span {
|
.tree-grid .section-title span {
|
||||||
color: #475569;
|
color: var(--text-subtle);
|
||||||
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 #c2410c;
|
outline: 2px solid var(--grid-danger);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
background: #fff7ed !important;
|
background: var(--grid-warn-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .cell-invalid input {
|
.tree-grid .cell-invalid input {
|
||||||
border-color: #c2410c;
|
border-color: var(--grid-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 #9fb6e0;
|
border: 1px solid var(--input-border);
|
||||||
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 {
|
||||||
|
|
@ -1095,26 +1269,27 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .action-cell button {
|
.tree-grid .action-cell button {
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid var(--panel-border-strong);
|
||||||
background: #fff;
|
background: var(--panel-bg);
|
||||||
|
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 #4c7dd9;
|
box-shadow: inset 0 0 0 2px var(--accent-blue-strong-border);
|
||||||
background: #eef4ff !important;
|
background: var(--accent-blue-soft-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .drop-target-invalid {
|
.tree-grid .drop-target-invalid {
|
||||||
box-shadow: inset 0 0 0 2px #d97706;
|
box-shadow: inset 0 0 0 2px var(--grid-warn);
|
||||||
background: #fff7ed !important;
|
background: var(--grid-warn-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .drop-target-confirm {
|
.tree-grid .drop-target-confirm {
|
||||||
box-shadow: inset 0 0 0 2px #d97706;
|
box-shadow: inset 0 0 0 2px var(--grid-warn);
|
||||||
background: #fffbeb !important;
|
background: var(--grid-warn-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid tr.circuit-insert-before td,
|
.tree-grid tr.circuit-insert-before td,
|
||||||
|
|
@ -1128,7 +1303,7 @@ a.kpi:hover {
|
||||||
left: -1px;
|
left: -1px;
|
||||||
right: -1px;
|
right: -1px;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
border-top: 4px solid #2563eb;
|
border-top: 4px solid var(--accent-blue);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1141,7 +1316,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 #2563eb;
|
border-left: 10px solid var(--accent-blue);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1151,7 +1326,7 @@ a.kpi:hover {
|
||||||
left: -1px;
|
left: -1px;
|
||||||
right: -1px;
|
right: -1px;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
border-bottom: 4px solid #2563eb;
|
border-bottom: 4px solid var(--accent-blue);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1164,13 +1339,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 #2563eb;
|
border-left: 10px solid var(--accent-blue);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-hint {
|
.drop-hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #1f4ea3;
|
color: var(--accent-blue-marker);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1178,21 +1353,22 @@ 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: #ebf3ff;
|
background: var(--notice-info-bg);
|
||||||
border-color: #bad1f7;
|
border-color: var(--notice-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.error {
|
.notice.error {
|
||||||
background: #fdecec;
|
background: var(--notice-error-bg);
|
||||||
border-color: #f5b5b5;
|
border-color: var(--notice-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.warning {
|
.notice.warning {
|
||||||
background: #fff7ed;
|
background: var(--notice-warning-bg);
|
||||||
border-color: #fdba74;
|
border-color: var(--notice-warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-error-notice {
|
.editor-error-notice {
|
||||||
|
|
@ -1203,12 +1379,13 @@ a.kpi:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.muted {
|
.notice.muted {
|
||||||
background: #f6f6f6;
|
background: var(--notice-muted-bg);
|
||||||
border-color: #e4e4e4;
|
border-color: var(--notice-muted-border);
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-hint {
|
.todo-hint {
|
||||||
color: #6b7280;
|
color: var(--text-faint);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
@ -8,10 +9,33 @@ 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">
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
|
{THEME_INIT_SCRIPT}
|
||||||
|
</Script>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
listProjects(),
|
getProject(projectId),
|
||||||
listDistributionBoards(projectId),
|
listDistributionBoards(projectId),
|
||||||
listCircuitLists(projectId),
|
listCircuitLists(projectId),
|
||||||
listFloors(projectId),
|
listFloors(projectId),
|
||||||
|
|
@ -138,7 +138,7 @@ export default function ProjectDetailPage() {
|
||||||
listGlobalDevices(),
|
listGlobalDevices(),
|
||||||
])
|
])
|
||||||
.then(([
|
.then(([
|
||||||
projects,
|
currentProject,
|
||||||
distributionBoards,
|
distributionBoards,
|
||||||
loadedCircuitLists,
|
loadedCircuitLists,
|
||||||
loadedFloors,
|
loadedFloors,
|
||||||
|
|
@ -146,7 +146,6 @@ 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);
|
||||||
|
|
|
||||||
10
src/app/web-health/route.ts
Normal file
10
src/app/web-health/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// Liveness probe for the web container itself. The "/health" path is
|
||||||
|
// rewritten to the API in next.config.mjs, so it cannot answer for this
|
||||||
|
// process. Kept as a route handler so a probe does not render a page.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
2
src/db/migrations/0006_damp_skrulls.sql
Normal file
2
src/db/migrations/0006_damp_skrulls.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`);
|
||||||
2419
src/db/migrations/meta/0006_snapshot.json
Normal file
2419
src/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -43,12 +43,6 @@ 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,
|
||||||
|
|
@ -105,15 +99,3 @@ 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, ne } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertCircuitUpdateProjectCommand,
|
assertCircuitUpdateProjectCommand,
|
||||||
createCircuitUpdateProjectCommand,
|
createCircuitUpdateProjectCommand,
|
||||||
|
|
@ -21,6 +21,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const duplicate = database
|
assertEquipmentIdentifierAvailable(
|
||||||
.select({ id: circuits.id })
|
database,
|
||||||
.from(circuits)
|
circuit.circuitListId,
|
||||||
.where(
|
equipmentIdentifier,
|
||||||
and(
|
circuit.id
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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
|
||||||
|
|
@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository
|
||||||
if (existingCircuit) {
|
if (existingCircuit) {
|
||||||
throw new Error("Circuit id already exists.");
|
throw new Error("Circuit id already exists.");
|
||||||
}
|
}
|
||||||
const duplicateIdentifier = database
|
assertEquipmentIdentifierAvailable(
|
||||||
.select({ id: circuits.id })
|
database,
|
||||||
.from(circuits)
|
snapshot.circuitListId,
|
||||||
.where(
|
snapshot.equipmentIdentifier
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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
|
||||||
|
|
@ -94,6 +95,11 @@ 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)
|
||||||
|
|
@ -187,6 +193,17 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitListEquipmentIdentifiers } from "../schema/circuit-list-equipment-identifiers.js";
|
||||||
|
|
||||||
|
export function normalizeEquipmentIdentifier(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DB-level normalized unique index uses SQLite's built-in lower(),
|
||||||
|
* which only folds ASCII a-z and leaves German characters (Ä/Ö/Ü/ß/…)
|
||||||
|
* untouched, so it alone would let e.g. "Ä1" and "ä1" coexist. This check
|
||||||
|
* normalizes with JS's Unicode-aware toLowerCase() against every
|
||||||
|
* identifier already registered for the circuit list (circuits and
|
||||||
|
* distribution-board components share one BMK namespace via
|
||||||
|
* circuit_list_equipment_identifiers), catching what the DB index cannot.
|
||||||
|
*/
|
||||||
|
export function assertEquipmentIdentifierAvailable(
|
||||||
|
database: AppDatabase,
|
||||||
|
circuitListId: string,
|
||||||
|
equipmentIdentifier: string,
|
||||||
|
excludeOwnerId?: string
|
||||||
|
): void {
|
||||||
|
const candidate = normalizeEquipmentIdentifier(equipmentIdentifier);
|
||||||
|
const existing = database
|
||||||
|
.select({
|
||||||
|
ownerId: circuitListEquipmentIdentifiers.ownerId,
|
||||||
|
equipmentIdentifier: circuitListEquipmentIdentifiers.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(circuitListEquipmentIdentifiers)
|
||||||
|
.where(eq(circuitListEquipmentIdentifiers.circuitListId, circuitListId))
|
||||||
|
.all();
|
||||||
|
const duplicate = existing.some(
|
||||||
|
(row) =>
|
||||||
|
row.ownerId !== excludeOwnerId &&
|
||||||
|
normalizeEquipmentIdentifier(row.equipmentIdentifier) === candidate
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -212,7 +212,9 @@ function replaceExternalState(
|
||||||
if (target.roomMappings.length) {
|
if (target.roomMappings.length) {
|
||||||
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||||
}
|
}
|
||||||
database.insert(externalModelObjects).values(target.objects).run();
|
if (target.objects.length) {
|
||||||
|
database.insert(externalModelObjects).values(target.objects).run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCanonicalBase64(value: string) {
|
function decodeCanonicalBase64(value: string) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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
|
||||||
|
|
@ -90,12 +91,11 @@ 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.");
|
||||||
}
|
}
|
||||||
if (database.select({ id: circuits.id }).from(circuits).where(and(
|
assertEquipmentIdentifierAvailable(
|
||||||
eq(circuits.circuitListId, circuit.circuitListId),
|
database,
|
||||||
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
|
circuit.circuitListId,
|
||||||
)).get()) {
|
circuit.equipmentIdentifier
|
||||||
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.");
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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";
|
||||||
|
|
@ -119,9 +120,32 @@ 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(assignment.target)
|
.set(values)
|
||||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,38 @@
|
||||||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import { circuits } from "./circuits.js";
|
import { 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("circuit_device_rows", {
|
export const circuitDeviceRows = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
"circuit_device_rows",
|
||||||
circuitId: text("circuit_id")
|
{
|
||||||
.notNull()
|
id: text("id").primaryKey(),
|
||||||
.references(() => circuits.id, { onDelete: "cascade" }),
|
circuitId: text("circuit_id")
|
||||||
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
|
.notNull()
|
||||||
onDelete: "set null",
|
.references(() => circuits.id, { onDelete: "cascade" }),
|
||||||
}),
|
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
onDelete: "set null",
|
||||||
name: text("name").notNull(),
|
}),
|
||||||
displayName: text("display_name").notNull(),
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
phaseType: text("phase_type"),
|
name: text("name").notNull(),
|
||||||
connectionKind: text("connection_kind"),
|
displayName: text("display_name").notNull(),
|
||||||
costGroup: text("cost_group"),
|
phaseType: text("phase_type"),
|
||||||
category: text("category"),
|
connectionKind: text("connection_kind"),
|
||||||
level: text("level"),
|
costGroup: text("cost_group"),
|
||||||
roomId: text("room_id").references(() => rooms.id, {
|
category: text("category"),
|
||||||
onDelete: "set null",
|
level: text("level"),
|
||||||
}),
|
roomId: text("room_id").references(() => rooms.id, {
|
||||||
roomNumberSnapshot: text("room_number_snapshot"),
|
onDelete: "set null",
|
||||||
roomNameSnapshot: text("room_name_snapshot"),
|
}),
|
||||||
quantity: integer("quantity").notNull(),
|
roomNumberSnapshot: text("room_number_snapshot"),
|
||||||
manualQuantity: integer("manual_quantity").notNull().default(0),
|
roomNameSnapshot: text("room_name_snapshot"),
|
||||||
powerPerUnit: real("power_per_unit").notNull(),
|
quantity: integer("quantity").notNull(),
|
||||||
simultaneityFactor: real("simultaneity_factor").notNull(),
|
manualQuantity: integer("manual_quantity").notNull().default(0),
|
||||||
cosPhi: real("cos_phi"),
|
powerPerUnit: real("power_per_unit").notNull(),
|
||||||
remark: text("remark"),
|
simultaneityFactor: real("simultaneity_factor").notNull(),
|
||||||
overriddenFields: text("overridden_fields"),
|
cosPhi: real("cos_phi"),
|
||||||
});
|
remark: text("remark"),
|
||||||
|
overriddenFields: text("overridden_fields"),
|
||||||
|
},
|
||||||
|
(table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)]
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
import { circuitLists } from "./circuit-lists.js";
|
import { circuitLists } from "./circuit-lists.js";
|
||||||
import { circuitSections } from "./circuit-sections.js";
|
import { circuitSections } from "./circuit-sections.js";
|
||||||
|
|
||||||
|
|
@ -26,6 +26,9 @@ 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) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
|
(table) => [
|
||||||
|
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
|
||||||
|
index("circuits_section_id_idx").on(table.sectionId),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,9 @@ 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") {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,9 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
export interface CircuitDeviceRow {
|
|
||||||
id: string;
|
|
||||||
circuitId: string;
|
|
||||||
linkedProjectDeviceId?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
phaseType?: string;
|
|
||||||
connectionKind?: string;
|
|
||||||
costGroup?: string;
|
|
||||||
category?: string;
|
|
||||||
level?: string;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumberSnapshot?: string;
|
|
||||||
roomNameSnapshot?: string;
|
|
||||||
quantity: number;
|
|
||||||
manualQuantity: number;
|
|
||||||
powerPerUnit: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi?: number;
|
|
||||||
remark?: string;
|
|
||||||
overriddenFields?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand(
|
||||||
if (target !== null) {
|
if (target !== null) {
|
||||||
assertCircuitProtectionSnapshot(target, circuitId);
|
assertCircuitProtectionSnapshot(target, circuitId);
|
||||||
}
|
}
|
||||||
if (JSON.stringify(expected) === JSON.stringify(target)) {
|
if (circuitProtectionSnapshotsEqual(expected, 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 ||
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export interface CircuitSection {
|
|
||||||
id: string;
|
|
||||||
circuitListId: string;
|
|
||||||
key: string;
|
|
||||||
displayName: string;
|
|
||||||
prefix: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export interface Circuit {
|
|
||||||
id: string;
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
cableLength?: number;
|
|
||||||
rcdAssignment?: string;
|
|
||||||
terminalDesignation?: string;
|
|
||||||
voltage?: number;
|
|
||||||
controlRequirement?: string;
|
|
||||||
status?: string;
|
|
||||||
isReserve: boolean;
|
|
||||||
remark?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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.nonnegative(),
|
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||||
cosPhi: finiteNumberSchema.positive().nullable(),
|
cosPhi: finiteNumberSchema.positive().nullable(),
|
||||||
remark: nullableStringSchema,
|
remark: nullableStringSchema,
|
||||||
overriddenFields: nullableStringSchema,
|
overriddenFields: nullableStringSchema,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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: "↺" },
|
||||||
|
|
@ -135,6 +136,9 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/frontend/components/ThemeToggle.tsx
Normal file
63
src/frontend/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = "leistungsbilanz:theme";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>("light");
|
||||||
|
|
||||||
|
// The inline script in layout.tsx already applied the persisted/system
|
||||||
|
// theme before hydration; read it back so the toggle starts in sync
|
||||||
|
// instead of flashing to "light" first.
|
||||||
|
useEffect(() => {
|
||||||
|
const current = document.documentElement.getAttribute("data-bs-theme");
|
||||||
|
setTheme(current === "dark" ? "dark" : "light");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
if (
|
||||||
|
event.key === THEME_STORAGE_KEY &&
|
||||||
|
(event.newValue === "dark" || event.newValue === "light")
|
||||||
|
) {
|
||||||
|
setTheme(event.newValue);
|
||||||
|
applyTheme(event.newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||||
|
setTheme(next);
|
||||||
|
applyTheme(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Private browsing or disabled storage: theme still applies for
|
||||||
|
// this page load, just without persistence across reloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-pressed={theme === "dark"}
|
||||||
|
className="sidebar-theme-toggle"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="sidebar-icon" aria-hidden="true">
|
||||||
|
{theme === "dark" ? "☀" : "☾"}
|
||||||
|
</span>
|
||||||
|
{theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -268,6 +268,10 @@ 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] =
|
||||||
|
|
@ -723,6 +727,27 @@ 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]
|
||||||
|
|
@ -1046,6 +1071,10 @@ 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);
|
||||||
|
|
@ -1056,6 +1085,7 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1063,6 +1093,10 @@ 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);
|
||||||
|
|
@ -1088,6 +1122,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -1744,7 +1779,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(sectionId);
|
const next = await getNextCircuitIdentifier(projectId, 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);
|
||||||
|
|
@ -1933,7 +1968,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(sectionId);
|
const next = await getNextCircuitIdentifier(projectId, sectionId);
|
||||||
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
|
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
|
||||||
const circuit = createCircuitSnapshot({
|
const circuit = createCircuitSnapshot({
|
||||||
sectionId,
|
sectionId,
|
||||||
|
|
@ -2127,7 +2162,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(sectionId);
|
const next = await getNextCircuitIdentifier(projectId, 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(
|
||||||
|
|
@ -2538,7 +2573,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(intent.sectionId);
|
const next = await getNextCircuitIdentifier(projectId, intent.sectionId);
|
||||||
const sortOrder =
|
const sortOrder =
|
||||||
intent.targetCircuitId && intent.placement
|
intent.targetCircuitId && intent.placement
|
||||||
? getAdjacentInsertionSortOrder(
|
? getAdjacentInsertionSortOrder(
|
||||||
|
|
@ -3699,6 +3734,7 @@ 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,
|
||||||
|
|
@ -3716,6 +3752,7 @@ 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,
|
||||||
|
|
@ -3733,6 +3770,7 @@ 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(
|
||||||
|
|
@ -3758,6 +3796,7 @@ 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"
|
||||||
|
|
@ -3815,13 +3854,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
>
|
>
|
||||||
Gruppen-FI hinzufügen
|
Gruppen-FI hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||||
|
>
|
||||||
Stromkreis hinzufügen
|
Stromkreis hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disabled={hasActiveSortOrFilter}
|
disabled={hasActiveSortOrFilter || isSaving}
|
||||||
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}
|
||||||
>
|
>
|
||||||
|
|
@ -4426,6 +4470,7 @@ 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
|
||||||
|
|
@ -4433,6 +4478,7 @@ 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
|
||||||
|
|
@ -4440,7 +4486,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{row.device ? (
|
{row.device ? (
|
||||||
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||||
Gerät löschen
|
Gerät löschen
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { type FormEvent, type ReactNode } from "react";
|
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface FormModalProps {
|
interface FormModalProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -14,6 +14,9 @@ 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,
|
||||||
|
|
@ -25,11 +28,56 @@ 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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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;
|
||||||
|
|
@ -131,319 +132,278 @@ export function ProjectSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FormModal
|
||||||
<div
|
description="Stammdaten und elektrische Standardwerte des Projekts"
|
||||||
aria-labelledby="project-settings-title"
|
isSaving={isSaving}
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
className="modal fade show d-block"
|
onSubmit={handleSubmit}
|
||||||
role="dialog"
|
submitDisabled={!isValid}
|
||||||
tabIndex={-1}
|
submitLabel="Einstellungen speichern"
|
||||||
>
|
title="Projekteinstellungen"
|
||||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
>
|
||||||
<form className="modal-content" onSubmit={handleSubmit}>
|
<div className="row g-3">
|
||||||
<div className="modal-header">
|
<div className="col-12">
|
||||||
<div>
|
<label className="form-label" htmlFor="project-name">
|
||||||
<h2 className="modal-title fs-5" id="project-settings-title">
|
Projektname
|
||||||
Projekteinstellungen
|
</label>
|
||||||
</h2>
|
<input
|
||||||
<p className="text-secondary small mb-0">
|
autoFocus
|
||||||
Stammdaten und elektrische Standardwerte des Projekts
|
className="form-control"
|
||||||
</p>
|
id="project-name"
|
||||||
</div>
|
maxLength={200}
|
||||||
<button
|
onChange={(event) => setName(event.target.value)}
|
||||||
aria-label="Schließen"
|
required
|
||||||
className="btn-close"
|
value={name}
|
||||||
disabled={isSaving}
|
/>
|
||||||
onClick={onClose}
|
</div>
|
||||||
type="button"
|
<div className="col-12 col-md-6">
|
||||||
/>
|
<label className="form-label" htmlFor="internal-project-number">
|
||||||
</div>
|
Projektnummer intern
|
||||||
<div className="modal-body">
|
</label>
|
||||||
<div className="row g-3">
|
<input
|
||||||
<div className="col-12">
|
className="form-control"
|
||||||
<label className="form-label" htmlFor="project-name">
|
id="internal-project-number"
|
||||||
Projektname
|
maxLength={100}
|
||||||
</label>
|
onChange={(event) =>
|
||||||
<input
|
setInternalProjectNumber(event.target.value)
|
||||||
autoFocus
|
}
|
||||||
className="form-control"
|
value={internalProjectNumber}
|
||||||
id="project-name"
|
/>
|
||||||
maxLength={200}
|
</div>
|
||||||
onChange={(event) => setName(event.target.value)}
|
<div className="col-12 col-md-6">
|
||||||
required
|
<label className="form-label" htmlFor="external-project-number">
|
||||||
value={name}
|
Projektnummer extern
|
||||||
/>
|
</label>
|
||||||
</div>
|
<input
|
||||||
<div className="col-12 col-md-6">
|
className="form-control"
|
||||||
<label className="form-label" htmlFor="internal-project-number">
|
id="external-project-number"
|
||||||
Projektnummer intern
|
maxLength={100}
|
||||||
</label>
|
onChange={(event) =>
|
||||||
<input
|
setExternalProjectNumber(event.target.value)
|
||||||
className="form-control"
|
}
|
||||||
id="internal-project-number"
|
value={externalProjectNumber}
|
||||||
maxLength={100}
|
/>
|
||||||
onChange={(event) =>
|
</div>
|
||||||
setInternalProjectNumber(event.target.value)
|
<div className="col-12">
|
||||||
}
|
<fieldset>
|
||||||
value={internalProjectNumber}
|
<legend className="form-label mb-1">
|
||||||
/>
|
Verwendete Netzarten
|
||||||
</div>
|
</legend>
|
||||||
<div className="col-12 col-md-6">
|
<p className="form-text mt-0">
|
||||||
<label className="form-label" htmlFor="external-project-number">
|
Nur ausgewählte Netzarten stehen bei Verteilungen zur
|
||||||
Projektnummer extern
|
Auswahl. Bereits verwendete Netzarten können nicht
|
||||||
</label>
|
deaktiviert werden.
|
||||||
<input
|
</p>
|
||||||
className="form-control"
|
<div className="row g-2">
|
||||||
id="external-project-number"
|
{distributionBoardSupplyTypes.map((supplyType) => (
|
||||||
maxLength={100}
|
<div
|
||||||
onChange={(event) =>
|
className="col-12 col-md-6"
|
||||||
setExternalProjectNumber(event.target.value)
|
key={supplyType}
|
||||||
}
|
>
|
||||||
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={isPublicBuilding}
|
checked={enabledDistributionBoardSupplyTypes.includes(
|
||||||
|
supplyType
|
||||||
|
)}
|
||||||
className="form-check-input"
|
className="form-check-input"
|
||||||
id="public-building"
|
disabled={usedDistributionBoardSupplyTypes.includes(
|
||||||
onChange={(event) =>
|
supplyType
|
||||||
setIsPublicBuilding(event.target.checked)
|
)}
|
||||||
}
|
onChange={() => toggleSupplyType(supplyType)}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span className="form-check-label">
|
<span className="form-check-label">
|
||||||
Öffentliches Gebäude
|
{distributionBoardSupplyTypeLabels[supplyType]}
|
||||||
|
{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>
|
||||||
</div>
|
) : null}
|
||||||
<div className="modal-footer">
|
</fieldset>
|
||||||
<button
|
</div>
|
||||||
className="btn btn-outline-secondary"
|
<div className="col-12">
|
||||||
disabled={isSaving}
|
<label className="form-label" htmlFor="building-owner">
|
||||||
onClick={onClose}
|
Bauherr
|
||||||
type="button"
|
</label>
|
||||||
>
|
<input
|
||||||
Abbrechen
|
className="form-control"
|
||||||
</button>
|
id="building-owner"
|
||||||
<button
|
maxLength={200}
|
||||||
className="btn btn-primary"
|
onChange={(event) => setBuildingOwner(event.target.value)}
|
||||||
disabled={isSaving || !isValid}
|
value={buildingOwner}
|
||||||
type="submit"
|
/>
|
||||||
>
|
</div>
|
||||||
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
|
<div className="col-12">
|
||||||
</button>
|
<label className="form-label" htmlFor="project-description">
|
||||||
</div>
|
Beschreibung
|
||||||
</form>
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="project-description"
|
||||||
|
maxLength={2000}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={isPublicBuilding}
|
||||||
|
className="form-check-input"
|
||||||
|
id="public-building"
|
||||||
|
onChange={(event) =>
|
||||||
|
setIsPublicBuilding(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Öffentliches Gebäude
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-text ms-4">
|
||||||
|
Wird bei der späteren Leitungsauslegung berücksichtigt,
|
||||||
|
insbesondere bei der Auswahl halogenfreier Kabel und
|
||||||
|
Leitungen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="single-phase-voltage">
|
||||||
|
Standardspannung 1-phasig [V]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="single-phase-voltage"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) =>
|
||||||
|
setSinglePhaseVoltageV(event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={singlePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="three-phase-voltage">
|
||||||
|
Standardspannung 3-phasig [V]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="three-phase-voltage"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) =>
|
||||||
|
setThreePhaseVoltageV(event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={threePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<hr className="my-2" />
|
||||||
|
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||||
|
<p className="text-secondary small">
|
||||||
|
Der Export enthält den vollständigen unterstützten
|
||||||
|
Projektzustand. Beim Duplizieren bleibt dieses Projekt
|
||||||
|
unverändert.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary mb-3"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void onExport()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei herunterladen
|
||||||
|
</button>
|
||||||
|
<label className="form-label d-block" htmlFor="project-import">
|
||||||
|
Projektdatei auswählen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="form-control"
|
||||||
|
id="project-import"
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleTransferFile(event.target.files?.[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{transferFilename ? (
|
||||||
|
<div className="form-text">{transferFilename} ist bereit.</div>
|
||||||
|
) : null}
|
||||||
|
{fileError ? (
|
||||||
|
<div className="text-danger small mt-1">{fileError}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="d-flex flex-wrap gap-3 mt-3">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "duplicate"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("duplicate")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Als neues Projekt duplizieren
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "replace"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("replace")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Dieses Projekt ersetzen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{importMode === "replace" ? (
|
||||||
|
<label className="form-check mt-2">
|
||||||
|
<input
|
||||||
|
checked={replaceConfirmed}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplaceConfirmed(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label text-danger">
|
||||||
|
Ich bestätige, dass der aktuelle Projektstand ersetzt
|
||||||
|
wird.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger mt-3"
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
transfer === null ||
|
||||||
|
(importMode === "replace" && !replaceConfirmed)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
transfer === null
|
||||||
|
? undefined
|
||||||
|
: void onImport(transfer, importMode)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei importieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-backdrop fade show" />
|
</FormModal>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -688,9 +688,9 @@ export function deleteCircuitCommand(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextCircuitIdentifier(sectionId: string) {
|
export function getNextCircuitIdentifier(projectId: string, sectionId: string) {
|
||||||
return request<{ sectionId: string; nextIdentifier: string }>(
|
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||||
`/api/circuit-sections/${sectionId}/next-identifier`
|
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,16 @@ 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}`);
|
||||||
|
|
|
||||||
|
|
@ -169,13 +169,6 @@ 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: {
|
||||||
|
|
|
||||||
|
|
@ -259,22 +259,28 @@ export function buildCircuitGroupRenumberPlan(
|
||||||
targetGroupNumber,
|
targetGroupNumber,
|
||||||
expectedPrefix,
|
expectedPrefix,
|
||||||
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
|
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
|
||||||
circuits: group.circuits.map((circuit) => {
|
circuits: [...group.circuits]
|
||||||
const circuitNumber = parseCircuitNumber(
|
.sort(
|
||||||
circuit.equipmentIdentifier,
|
(left, right) =>
|
||||||
category,
|
left.sortOrder - right.sortOrder ||
|
||||||
group.groupNumber
|
left.id.localeCompare(right.id)
|
||||||
);
|
)
|
||||||
return {
|
.map((circuit) => {
|
||||||
circuitId: circuit.id,
|
const circuitNumber = parseCircuitNumber(
|
||||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
circuit.equipmentIdentifier,
|
||||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
|
||||||
category,
|
category,
|
||||||
targetGroupNumber,
|
group.groupNumber
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,13 @@ export function buildCircuitSectionRenumberAssignments(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const orderedCircuits = [...targetSection.circuits].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
const assignments: CircuitSectionRenumberAssignment[] = [];
|
const assignments: CircuitSectionRenumberAssignment[] = [];
|
||||||
let suffix = 1;
|
let suffix = 1;
|
||||||
for (const circuit of targetSection.circuits) {
|
for (const circuit of orderedCircuits) {
|
||||||
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
|
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
|
||||||
while (
|
while (
|
||||||
identifiersOutsideSection.has(targetEquipmentIdentifier)
|
identifiersOutsideSection.has(targetEquipmentIdentifier)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
|
||||||
component,
|
component,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const circuit of section.circuits) {
|
for (const circuit of ordered(section.circuits)) {
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `circuit-block:${circuit.id}`,
|
rowKey: `circuit-block:${circuit.id}`,
|
||||||
rowType: "circuitBlock",
|
rowType: "circuitBlock",
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,24 @@ 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(
|
||||||
|
|
|
||||||
27
src/instrumentation-node.ts
Normal file
27
src/instrumentation-node.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { createLogger, toErrorMeta } from "./shared/logging/logger";
|
||||||
|
|
||||||
|
export function registerNodeInstrumentation() {
|
||||||
|
const logger = createLogger("web");
|
||||||
|
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
logger.info("web server starting", { pid: process.pid });
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
logger.error("uncaught exception, exiting", toErrorMeta(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
logger.verbose("heartbeat", {
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
rssMb: Math.round(memory.rss / 1024 / 1024),
|
||||||
|
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
|
||||||
|
});
|
||||||
|
}, heartbeatIntervalMs).unref();
|
||||||
|
}
|
||||||
8
src/instrumentation.ts
Normal file
8
src/instrumentation.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { registerNodeInstrumentation } = await import("./instrumentation-node");
|
||||||
|
registerNodeInstrumentation();
|
||||||
|
}
|
||||||
24
src/proxy.ts
Normal file
24
src/proxy.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createLogger } from "./shared/logging/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("web:navigation");
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
// Probes hit "/web-health" and are excluded by the matcher below. The
|
||||||
|
// User-Agent guard stays as a fallback for anything else that polls
|
||||||
|
// without one, so real navigation isn't drowned out in the logs.
|
||||||
|
if (request.headers.get("user-agent")) {
|
||||||
|
logger.info("page request", {
|
||||||
|
method: request.method,
|
||||||
|
path: request.nextUrl.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
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 { sectionId } = req.params;
|
const { projectId, sectionId } = req.params;
|
||||||
if (typeof sectionId !== "string") {
|
if (typeof projectId !== "string" || typeof sectionId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
|
}
|
||||||
|
const section = await circuitSectionRepository.findById(sectionId);
|
||||||
|
const list = section
|
||||||
|
? await circuitListRepository.findById(projectId, section.circuitListId)
|
||||||
|
: null;
|
||||||
|
if (!section || !list) {
|
||||||
|
return res.status(404).json({ error: "Section not found" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const nextIdentifier =
|
const nextIdentifier =
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,12 @@ 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() });
|
||||||
}
|
}
|
||||||
|
|
||||||
await globalDeviceRepository.update(globalDeviceId, parsed.data);
|
const existing = await globalDeviceRepository.findById(globalDeviceId);
|
||||||
const row = await globalDeviceRepository.findById(globalDeviceId);
|
if (!existing) {
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: "Global device not found" });
|
return res.status(404).json({ error: "Global device not found" });
|
||||||
}
|
}
|
||||||
|
await globalDeviceRepository.update(globalDeviceId, parsed.data);
|
||||||
|
const row = await globalDeviceRepository.findById(globalDeviceId);
|
||||||
return res.json(row);
|
return res.json(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,79 @@
|
||||||
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);
|
||||||
|
|
||||||
app.listen(port, () => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.log(`Server running on http://localhost:${port}`);
|
logger.error("uncaught exception, exiting", toErrorMeta(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
||||||
|
process.on("SIGINT", () => logger.info("received SIGINT"));
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
logger.verbose("heartbeat", {
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
rssMb: Math.round(memory.rss / 1024 / 1024),
|
||||||
|
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
|
||||||
|
});
|
||||||
|
}, heartbeatIntervalMs).unref();
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info("server started", { port });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
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
|
||||||
) {
|
) {
|
||||||
console.error(error);
|
logger.error("request handler threw", {
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
...toErrorMeta(error),
|
||||||
|
});
|
||||||
res.status(500).json({ error: "Internal Server Error" });
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
getNextCircuitIdentifier,
|
|
||||||
} from "../controllers/circuit.controller.js";
|
|
||||||
|
|
||||||
export const circuitRouter = Router();
|
|
||||||
|
|
||||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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,
|
||||||
|
|
@ -95,6 +96,10 @@ 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);
|
||||||
|
|
|
||||||
77
src/shared/logging/logger.ts
Normal file
77
src/shared/logging/logger.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
|
||||||
|
|
||||||
|
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
verbose: 3,
|
||||||
|
debug: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LEVEL: LogLevel = "info";
|
||||||
|
|
||||||
|
export function resolveLogLevel(raw: string | undefined): LogLevel {
|
||||||
|
const candidate = raw?.trim().toLowerCase();
|
||||||
|
if (candidate && candidate in LEVEL_SEVERITY) {
|
||||||
|
return candidate as LogLevel;
|
||||||
|
}
|
||||||
|
return DEFAULT_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toErrorMeta(error: unknown): Record<string, unknown> {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
errorName: error.name,
|
||||||
|
errorMessage: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { error: typeof error === "string" ? error : JSON.stringify(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
error(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
verbose(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
debug(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(
|
||||||
|
scope: string,
|
||||||
|
options?: { level?: LogLevel }
|
||||||
|
): Logger {
|
||||||
|
const threshold = options?.level ?? resolveLogLevel(process.env.LOG_LEVEL);
|
||||||
|
|
||||||
|
const write = (level: LogLevel, message: string, meta?: Record<string, unknown>) => {
|
||||||
|
if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
let line: string;
|
||||||
|
try {
|
||||||
|
line = JSON.stringify({ timestamp, level, scope, message, ...meta });
|
||||||
|
} catch {
|
||||||
|
line = JSON.stringify({
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
scope,
|
||||||
|
message,
|
||||||
|
logError: "failed to serialize log metadata",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (level === "error" || level === "warn") {
|
||||||
|
console.error(line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: (message, meta) => write("error", message, meta),
|
||||||
|
warn: (message, meta) => write("warn", message, meta),
|
||||||
|
info: (message, meta) => write("info", message, meta),
|
||||||
|
verbose: (message, meta) => write("verbose", message, meta),
|
||||||
|
debug: (message, meta) => write("debug", message, meta),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
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(),
|
||||||
|
|
@ -10,7 +15,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(24_000_000),
|
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
@ -21,7 +26,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(24_000_000),
|
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||||
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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
name: z.string().min(1).max(200),
|
||||||
displayName: z.string().min(1),
|
displayName: z.string().min(1).max(200),
|
||||||
category: z.string().optional(),
|
category: z.string().max(100).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().optional(),
|
note: z.string().max(2000).optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
name: z.string().min(1).max(200),
|
||||||
displayName: z.string().min(1),
|
displayName: z.string().min(1).max(200),
|
||||||
connectionKind: z.string().optional(),
|
connectionKind: z.string().max(100).optional(),
|
||||||
costGroup: z.string().optional(),
|
costGroup: z.string().max(100).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().optional(),
|
remark: z.string().max(2000).optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
name: z.string().trim().min(1).max(200),
|
||||||
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),
|
name: z.string().trim().min(1).max(200),
|
||||||
})
|
})
|
||||||
.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),
|
roomNumber: z.string().trim().min(1).max(50),
|
||||||
roomName: z.string().trim().min(1),
|
roomName: z.string().trim().min(1).max(200),
|
||||||
})
|
})
|
||||||
.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),
|
roomNumber: z.string().trim().min(1).max(50),
|
||||||
roomName: z.string().trim().min(1),
|
roomName: z.string().trim().min(1).max(200),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,25 @@ describe("circuit grid model", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses numeric drafts and rejects invalid values", () => {
|
it("parses numeric drafts and rejects invalid values", () => {
|
||||||
assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
|
assert.equal(parseNumeric("quantity", " 1500 "), 1500);
|
||||||
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
|
assert.equal(parseNumeric("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,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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,
|
||||||
|
|
@ -101,7 +100,7 @@ describe("circuit grid projection", () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
|
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
|
||||||
const rows = buildVisibleGridRows(projectedSections);
|
const rows = buildVisibleGridRowsWithStructure(projectedSections, { headerComponents: [], footerComponents: [] });
|
||||||
|
|
||||||
assert.equal(projectedSections.length, 2);
|
assert.equal(projectedSections.length, 2);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|
@ -150,7 +149,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 = buildVisibleGridRows(sections);
|
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
|
||||||
|
|
||||||
assert.deepEqual(rows.map((row) => row.rowType), [
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
||||||
"section",
|
"section",
|
||||||
|
|
@ -191,7 +190,7 @@ describe("circuit grid projection", () => {
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
const rows = buildVisibleGridRows(protectedSections);
|
const rows = buildVisibleGridRowsWithStructure(protectedSections, { headerComponents: [], footerComponents: [] });
|
||||||
const protectionValue = (rowType: string) =>
|
const protectionValue = (rowType: string) =>
|
||||||
rows
|
rows
|
||||||
.find((row) => row.rowType === rowType)
|
.find((row) => row.rowType === rowType)
|
||||||
|
|
|
||||||
|
|
@ -580,4 +580,43 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
100
tests/circuit.controller.test.ts
Normal file
100
tests/circuit.controller.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { getNextCircuitIdentifier } from "../src/server/controllers/circuit.controller.js";
|
||||||
|
import {
|
||||||
|
circuitListRepository,
|
||||||
|
circuitSectionRepository,
|
||||||
|
} from "../src/server/composition/application-repositories.js";
|
||||||
|
import { circuitNumberingService } from "../src/server/composition/circuit-numbering-service.js";
|
||||||
|
|
||||||
|
function createMockResponse() {
|
||||||
|
let statusCode = 200;
|
||||||
|
let body: unknown;
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
status(code: number) {
|
||||||
|
statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value: unknown) {
|
||||||
|
body = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getStatusCode: () => statusCode,
|
||||||
|
getBody: () => body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit controller", () => {
|
||||||
|
it("returns the next identifier when the section belongs to the project", async () => {
|
||||||
|
const originals = {
|
||||||
|
findSection: circuitSectionRepository.findById,
|
||||||
|
findList: circuitListRepository.findById,
|
||||||
|
getNextIdentifier: circuitNumberingService.getNextIdentifier,
|
||||||
|
};
|
||||||
|
circuitSectionRepository.findById = async () =>
|
||||||
|
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
|
||||||
|
circuitListRepository.findById = async (projectId: string, circuitListId: string) =>
|
||||||
|
projectId === "project-1" && circuitListId === "list-1"
|
||||||
|
? ({ id: "list-1", projectId: "project-1" } as never)
|
||||||
|
: null;
|
||||||
|
circuitNumberingService.getNextIdentifier = async () => "-1F3";
|
||||||
|
const mock = createMockResponse();
|
||||||
|
try {
|
||||||
|
await getNextCircuitIdentifier(
|
||||||
|
{ params: { projectId: "project-1", sectionId: "section-1" } } as never,
|
||||||
|
mock.response as never
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
circuitSectionRepository.findById = originals.findSection;
|
||||||
|
circuitListRepository.findById = originals.findList;
|
||||||
|
circuitNumberingService.getNextIdentifier = originals.getNextIdentifier;
|
||||||
|
}
|
||||||
|
assert.deepEqual(mock.getBody(), {
|
||||||
|
sectionId: "section-1",
|
||||||
|
nextIdentifier: "-1F3",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 instead of leaking numbering state for a section from another project", async () => {
|
||||||
|
const originals = {
|
||||||
|
findSection: circuitSectionRepository.findById,
|
||||||
|
findList: circuitListRepository.findById,
|
||||||
|
};
|
||||||
|
circuitSectionRepository.findById = async () =>
|
||||||
|
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
|
||||||
|
// The section exists, but its circuit list does not belong to the requesting project.
|
||||||
|
circuitListRepository.findById = async () => null;
|
||||||
|
const mock = createMockResponse();
|
||||||
|
try {
|
||||||
|
await getNextCircuitIdentifier(
|
||||||
|
{ params: { projectId: "foreign-project", sectionId: "section-1" } } as never,
|
||||||
|
mock.response as never
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
circuitSectionRepository.findById = originals.findSection;
|
||||||
|
circuitListRepository.findById = originals.findList;
|
||||||
|
}
|
||||||
|
assert.equal(mock.getStatusCode(), 404);
|
||||||
|
assert.deepEqual(mock.getBody(), { error: "Section not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a section that does not exist", async () => {
|
||||||
|
const originals = {
|
||||||
|
findSection: circuitSectionRepository.findById,
|
||||||
|
};
|
||||||
|
circuitSectionRepository.findById = async () => null;
|
||||||
|
const mock = createMockResponse();
|
||||||
|
try {
|
||||||
|
await getNextCircuitIdentifier(
|
||||||
|
{ params: { projectId: "project-1", sectionId: "missing" } } as never,
|
||||||
|
mock.response as never
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
circuitSectionRepository.findById = originals.findSection;
|
||||||
|
}
|
||||||
|
assert.equal(mock.getStatusCode(), 404);
|
||||||
|
assert.deepEqual(mock.getBody(), { error: "Section not found" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -580,7 +580,7 @@ describe("distribution-board component structure project command", () => {
|
||||||
snapshot
|
snapshot
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
/UNIQUE constraint failed/
|
/Duplicate equipmentIdentifier in circuit list\./
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
context.db.select().from(projectRevisions).all().length,
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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,
|
||||||
|
|
@ -74,7 +75,7 @@ describe("external CSV API contracts", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
previewExternalCsvSchema.safeParse({
|
previewExternalCsvSchema.safeParse({
|
||||||
fileName: "revit.csv",
|
fileName: "revit.csv",
|
||||||
contentBase64: "a".repeat(24_000_001),
|
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
|
||||||
}).success,
|
}).success,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,30 @@ describe("external initial import project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a target state with zero classified objects before writing anything", () => {
|
||||||
|
// assertPopulatedInitialState requires at least one object, so a CSV
|
||||||
|
// that classified none must be rejected at command construction, not
|
||||||
|
// reach the persistence layer's bulk insert.
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target: ExternalModelStateSnapshot = {
|
||||||
|
...initialState(configuration),
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
target
|
||||||
|
),
|
||||||
|
/at least one object/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects changed state, checksum drift and row assignment", () => {
|
it("rejects changed state, checksum drift and row assignment", () => {
|
||||||
const { context, configuration } = createTestContext();
|
const { context, configuration } = createTestContext();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
83
tests/logger.test.ts
Normal file
83
tests/logger.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createLogger, resolveLogLevel } from "../src/shared/logging/logger.js";
|
||||||
|
|
||||||
|
describe("resolveLogLevel", () => {
|
||||||
|
it("defaults to info for missing or unknown values", () => {
|
||||||
|
assert.equal(resolveLogLevel(undefined), "info");
|
||||||
|
assert.equal(resolveLogLevel(""), "info");
|
||||||
|
assert.equal(resolveLogLevel("nonsense"), "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts known levels case-insensitively", () => {
|
||||||
|
assert.equal(resolveLogLevel("DEBUG"), "debug");
|
||||||
|
assert.equal(resolveLogLevel(" verbose "), "verbose");
|
||||||
|
assert.equal(resolveLogLevel("warn"), "warn");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLogger", () => {
|
||||||
|
it("suppresses levels below the configured threshold", () => {
|
||||||
|
const infoLines: string[] = [];
|
||||||
|
const originalLog = console.log;
|
||||||
|
console.log = (line: string) => {
|
||||||
|
infoLines.push(line);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const logger = createLogger("test", { level: "warn" });
|
||||||
|
logger.info("should not appear");
|
||||||
|
logger.debug("should not appear");
|
||||||
|
logger.verbose("should not appear");
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
}
|
||||||
|
assert.equal(infoLines.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits enabled levels as structured JSON with scope and meta", () => {
|
||||||
|
const errorLines: string[] = [];
|
||||||
|
const originalError = console.error;
|
||||||
|
console.error = (line: string) => {
|
||||||
|
errorLines.push(line);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const logger = createLogger("test", { level: "warn" });
|
||||||
|
logger.warn("something happened", { code: 42 });
|
||||||
|
} finally {
|
||||||
|
console.error = originalError;
|
||||||
|
}
|
||||||
|
assert.equal(errorLines.length, 1);
|
||||||
|
const parsed = JSON.parse(errorLines[0]);
|
||||||
|
assert.equal(parsed.level, "warn");
|
||||||
|
assert.equal(parsed.scope, "test");
|
||||||
|
assert.equal(parsed.message, "something happened");
|
||||||
|
assert.equal(parsed.code, 42);
|
||||||
|
assert.equal(typeof parsed.timestamp, "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes debug/verbose/info to console.log and error/warn to console.error", () => {
|
||||||
|
const logLines: string[] = [];
|
||||||
|
const errorLines: string[] = [];
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
console.log = (line: string) => {
|
||||||
|
logLines.push(line);
|
||||||
|
};
|
||||||
|
console.error = (line: string) => {
|
||||||
|
errorLines.push(line);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const logger = createLogger("test", { level: "debug" });
|
||||||
|
logger.debug("d");
|
||||||
|
logger.verbose("v");
|
||||||
|
logger.info("i");
|
||||||
|
logger.warn("w");
|
||||||
|
logger.error("e");
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
console.error = originalError;
|
||||||
|
}
|
||||||
|
assert.equal(logLines.length, 3);
|
||||||
|
assert.equal(errorLines.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -209,6 +209,13 @@ describe("circuit device-row update project commands", () => {
|
||||||
}),
|
}),
|
||||||
/non-negative/
|
/non-negative/
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
simultaneityFactor: 1.5,
|
||||||
|
}),
|
||||||
|
/must not exceed 1/
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -508,6 +515,14 @@ describe("circuit device-row structure project commands", () => {
|
||||||
}),
|
}),
|
||||||
/must not be negative/
|
/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/
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@ 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,
|
||||||
|
|
@ -163,6 +167,79 @@ 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
|
||||||
|
|
@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ quantity: 5, manualQuantity: 5 })
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const expected = snapshot(fixture.context, "row-1");
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected,
|
||||||
|
target: { ...expected, quantity: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const row = getRow(fixture.context, "row-1");
|
||||||
|
assert.equal(row.quantity, 2);
|
||||||
|
assert.equal(row.manualQuantity, 2);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts linked external objects when a synced quantity shrinks", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ quantity: 5, manualQuantity: 2 })
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
linkExternalObject(fixture.context, "row-1", 3);
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const expected = snapshot(fixture.context, "row-1");
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const row = getRow(fixture.context, "row-1");
|
||||||
|
assert.equal(row.quantity, 4);
|
||||||
|
assert.equal(row.manualQuantity, 1);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a synced quantity below the linked external total", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ quantity: 5, manualQuantity: 2 })
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
linkExternalObject(fixture.context, "row-1", 3);
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const expected = snapshot(fixture.context, "row-1");
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/below the total quantity of linked external objects/
|
||||||
|
);
|
||||||
|
const row = getRow(fixture.context, "row-1");
|
||||||
|
assert.equal(row.quantity, 5);
|
||||||
|
assert.equal(row.manualQuantity, 2);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back synchronized rows for a stale project revision", () => {
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,32 @@ 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"),
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,11 @@
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"src/proxy.ts",
|
||||||
|
"src/instrumentation.ts",
|
||||||
|
"src/instrumentation-node.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@
|
||||||
"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"]
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,11 @@
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": ["scripts/**/*.ts", "src/**/*.ts"],
|
"include": ["scripts/**/*.ts", "src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"src/proxy.ts",
|
||||||
|
"src/instrumentation.ts",
|
||||||
|
"src/instrumentation-node.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue