diff --git a/.gitignore b/.gitignore index 8d44fb4..ed5ffe6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ data/*.db data/backups/*.db .codex/*.log +dynamo/output/ diff --git a/Dockerfile b/Dockerfile index 41a08fd..31d28c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,17 @@ -FROM node:22 +FROM node:24 WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . + +# next build writes the rewrite destinations from next.config.mjs into +# .next/routes-manifest.json, so "next start" cannot pick up a different +# API URL later. The value has to be known here, not just at runtime. +ARG API_INTERNAL_URL=http://localhost:3000 +ENV API_INTERNAL_URL=$API_INTERNAL_URL + RUN npm run build:api && npm run build:web RUN mkdir -p data && chmod +x scripts/docker-start.sh diff --git a/README.md b/README.md index c333917..fc82fca 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,29 @@ docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in [Deployment und Betrieb](docs/deployment.md). +Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit +Quellcode-Mounts und Watchern: + +```powershell +docker compose -f compose.dev.yaml up --build --detach +docker compose -f compose.dev.yaml logs --follow +docker compose -f compose.dev.yaml down +``` + +Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und +macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn +niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen +Server. + ## Direkte lokale Entwicklung Voraussetzungen: -- Node.js 22 +- Node.js 24 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..86e42e2 --- /dev/null +++ b/compose.dev.yaml @@ -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 diff --git a/compose.yaml b/compose.yaml index 2062cb7..503a4b2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,68 +1,73 @@ name: leistungsbilanz +x-build: &build + context: . + args: + # Baked into .next/routes-manifest.json by next build; see Dockerfile. + API_INTERNAL_URL: http://api:3000 + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + services: api: - build: - context: . + build: *build command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" - CHOKIDAR_USEPOLLING: "true" + LOG_LEVEL: "${LOG_LEVEL:-info}" init: true + restart: unless-stopped + logging: *logging ports: - "3000:3000" volumes: - - ./src:/app/src - - ./scripts:/app/scripts - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s web: - build: - context: . + build: *build command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 + - node_modules/.bin/next + - start + - -p + - "3001" environment: + NODE_ENV: production API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" + LOG_LEVEL: "${LOG_LEVEL:-info}" init: true + restart: unless-stopped + logging: *logging depends_on: api: condition: service_healthy ports: - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 4ed696b..9ed9e0a 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -359,8 +359,9 @@ Response sketch: ### Circuit Structure -- `GET /circuit-sections/:sectionId/next-identifier` +- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier` - preview next identifier for section (`prefix + maxSuffix + 1`) + - returns 404 if the section does not belong to the given project Circuit and device-row field updates, standalone insertions/deletions, single or bulk device-row moves, circuit reorders and explicit renumbering are diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 32f6814..92efc98 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -476,16 +476,28 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. und Verteilerkomponenten. Separate 1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt. - Ein triggergeführtes Register erzwingt bereits eine normalisierte, + Ein triggergeführtes Register erzwingt eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und - Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell - Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für - veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter - Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor - zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und - Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung, - -sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel, - geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert. + Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites + eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht + als denselben Wert. Die gemeinsame Prüfung + `src/db/repositories/equipment-identifier-uniqueness.persistence.ts` + schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem + `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird + von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten + aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und + Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente + Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, + Gruppen einschließlich befüllter Unterbäume sowie vollständige + Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur + an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte + Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite + Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes + Unterbaumlöschen und Stromkreisschutz sind integriert. + Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und + `circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten + Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und + Stromkreisen. PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen diff --git a/docs/deployment.md b/docs/deployment.md index 44c7dd5..06289a7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,19 +2,27 @@ ## Aktueller Status -Es gibt derzeit kein unterstütztes Produktionsdeployment. +Es gibt zwei Compose-Stacks. -`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet -`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, -Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes -Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder -öffentlich erreichbar gemacht werden. +`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und +`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack +für einen Server. -## Entwicklungs-Topologie +`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom +Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows +und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch +ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen +Entwicklungsrechner. + +Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch +ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich +erreichbar gemacht werden. + +## Topologie | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /` | keine | +| Next.js Web | 3001 | `GET /web-health` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -24,11 +32,56 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - Dateibeobachtung in Docker +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg +- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten + JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. + Setzbar über eine `.env`-Datei neben `compose.yaml` oder + `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst `npm run db:migrate` und -`npm run db:verify:circuit-schema`. +Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung +(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im +Entwicklungsstack über `npm run db:migrate` und +`npm run db:verify:circuit-schema`). + +`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` +schreibt die Rewrite-Ziele aus `next.config.mjs` fest in +`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als +Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. + +## Logging + +Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach +stdout/stderr (`docker compose logs --follow`). Jede Zeile enthält +`timestamp`, `level`, `scope` und `message`. `compose.yaml` konfiguriert für +beide Dienste den `json-file`-Treiber mit Rotation (`max-size: 20m`, +`max-file: 10`, also bis zu 200 MB je Dienst); ohne diese Einstellung würde +Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter +`/var/lib/docker/containers//` 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 diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py new file mode 100644 index 0000000..a3fe009 --- /dev/null +++ b/dynamo/01_check_model_identity.py @@ -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(), + } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py new file mode 100644 index 0000000..440a222 --- /dev/null +++ b/dynamo/02_export_electrical_fixture_parameter_inventory.py @@ -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(), + } diff --git a/dynamo/README.md b/dynamo/README.md new file mode 100644 index 0000000..800eded --- /dev/null +++ b/dynamo/README.md @@ -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. diff --git a/next.config.mjs b/next.config.mjs index e81b670..e0610b9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,12 @@ const apiInternalUrl = (process.env.API_INTERNAL_URL || "http://localhost:3000").replace(/\/$/, ""); const nextConfig = { + allowedDevOrigins: [ + "192.168.3.13", + "docker01.int.jappel.io", + "lb.jappel.io" + ], + typescript: { tsconfigPath: "./tsconfig.next.json", }, @@ -20,3 +26,4 @@ const nextConfig = { }; export default nextConfig; + diff --git a/package-lock.json b/package-lock.json index b0abb41..417421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" + }, + "engines": { + "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1514,12 +1517,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -3688,10 +3692,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 9bc5ae3..414eb53 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -10,6 +13,9 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", + "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", + "docker:dev:down": "docker compose -f compose.dev.yaml down", + "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", @@ -40,7 +46,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", diff --git a/src/app/globals.css b/src/app/globals.css index 9c07a40..22da5df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,7 +3,13 @@ Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt - per Ladereihenfolge) statt Bootstrap zu ersetzen. */ + per Ladereihenfolge) statt Bootstrap zu ersetzen. + + Dark mode: Bootstrap schaltet seine eigenen Komponenten (Formulare, + Modals, Cards, Alerts, ...) automatisch um, sobald `data-bs-theme="dark"` + auf steht (gesetzt von ThemeToggle.tsx, persistiert in + localStorage). Der custom Stromkreis-Grid nutzt dafür dieselben + Tokens unten, per `:root[data-bs-theme="dark"]` überschrieben. */ :root { --color-primary: #1c3f52; /* Petrol */ --color-primary-dark: #0f2733; /* Petrol Dark */ @@ -33,6 +39,105 @@ --space-xl: 40px; --radius: 12px; + + /* ── Grid/Editor-Tokens (hell) ──────────────────────────────────────── */ + --panel-bg: #ffffff; + --panel-bg-subtle: #f8fafc; + --panel-border: #d9dee8; + --panel-border-strong: #c4cddc; + --input-border: #9fb6e0; + --text-strong: #1f2937; + --text-muted: #4b5563; + --text-faint: #6b7280; + --text-subtle: #475569; + + --accent-blue: #2563eb; + --accent-blue-soft-bg: #eff6ff; + --accent-blue-border: #bfdbfe; + --accent-blue-strong-border: #4c7dd9; + --accent-blue-drop-border: #2b6cb0; + --accent-blue-marker: #1d4ed8; + + --surface-selected: #eaf1ff; + --surface-header-row: #e8eef8; + --surface-component-header: #e2e8f0; + --surface-component-group: #f8fafc; + --surface-component-footer: #f1f5f9; + --surface-hover: #f3f4f6; + + --grid-warn: #d97706; + --grid-warn-bg: #fff7ed; + --grid-warn-strong: #9a3412; + --grid-danger: #c2410c; + + --notice-info-bg: #ebf3ff; + --notice-info-border: #bad1f7; + --notice-error-bg: #fdecec; + --notice-error-border: #f5b5b5; + --notice-warning-bg: #fff7ed; + --notice-warning-border: #fdba74; + --notice-muted-bg: #f6f6f6; + --notice-muted-border: #e4e4e4; + + --shadow-soft: rgba(15, 39, 51, 0.07); + --shadow-strong: rgba(15, 39, 51, 0.28); + --shadow-menu: rgba(0, 0, 0, 0.12); + --shadow-drawer: rgba(31, 41, 55, 0.22); +} + +:root[data-bs-theme="dark"] { + --color-ink: #e6ecef; + --color-ink-soft: #9db0b8; + --color-bg: #141b20; + --color-surface: #0f1519; + --color-border: #2a3941; + --color-danger: #e2664f; + --color-danger-bg: #33201c; + --color-warn: #e0983f; + + /* ── Grid/Editor-Tokens (dunkel) ────────────────────────────────────── */ + --panel-bg: #182229; + --panel-bg-subtle: #1c2830; + --panel-border: #2c3c45; + --panel-border-strong: #374a54; + --input-border: #3c5568; + --text-strong: #e6ecef; + --text-muted: #aebdc4; + --text-faint: #8798a0; + --text-subtle: #9db0b8; + + --accent-blue: #6fa2f7; + --accent-blue-soft-bg: #17253a; + --accent-blue-border: #2c4a72; + --accent-blue-strong-border: #6fa2f7; + --accent-blue-drop-border: #4f84d6; + --accent-blue-marker: #6fa2f7; + + --surface-selected: #17253a; + --surface-header-row: #1a262f; + --surface-component-header: #202e37; + --surface-component-group: #1a262f; + --surface-component-footer: #182229; + --surface-hover: #1f2b33; + + --grid-warn: #e0983f; + --grid-warn-bg: #2e2214; + --grid-warn-strong: #f0b880; + --grid-danger: #e2825f; + + --notice-info-bg: #182839; + --notice-info-border: #2c4a72; + --notice-error-bg: #33201c; + --notice-error-border: #5c332c; + --notice-warning-bg: #2e2214; + --notice-warning-border: #6b4a1c; + --notice-muted-bg: #1b2226; + --notice-muted-border: #2a3338; + + --shadow-soft: rgba(0, 0, 0, 0.35); + --shadow-strong: rgba(0, 0, 0, 0.55); + --shadow-menu: rgba(0, 0, 0, 0.45); + --shadow-drawer: rgba(0, 0, 0, 0.55); } html { @@ -109,6 +214,12 @@ a { --bs-alert-border-color: #b6e2ce; } +:root[data-bs-theme="dark"] .alert-success { + --bs-alert-color: #7fd6a7; + --bs-alert-bg: #16291f; + --bs-alert-border-color: #205a38; +} + /* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */ .app-shell { @@ -165,6 +276,7 @@ a { flex-direction: column; gap: 2px; padding: 12px; + flex: 1; } .sidebar-section-label { @@ -210,6 +322,32 @@ a { justify-content: center; } +.sidebar-footer { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.sidebar-theme-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + color: #c7d6e0; + font-size: 0.92rem; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); + width: 100%; + text-align: left; + cursor: pointer; +} + +.sidebar-theme-toggle:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + .app-shell-content { width: 100%; min-width: 0; @@ -230,6 +368,10 @@ a { color: var(--color-primary); } +:root[data-bs-theme="dark"] .page-header h1 { + color: var(--color-accent-pale); +} + .kicker { font-size: 0.72rem; letter-spacing: 0.12em; @@ -262,7 +404,7 @@ a { a.kpi:hover { color: inherit; text-decoration: none; - box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28); + box-shadow: 0 3px 8px var(--shadow-soft), 0 16px 32px -14px var(--shadow-strong); transform: translateY(-1px); } @@ -285,12 +427,16 @@ a.kpi:hover { color: var(--color-primary); } +:root[data-bs-theme="dark"] .kpi-value { + color: var(--color-accent-pale); +} + .card { border-radius: var(--radius); border-top: 3px solid var(--color-primary); box-shadow: - 0 1px 2px rgba(15, 39, 51, 0.05), - 0 10px 24px -14px rgba(15, 39, 51, 0.22); + 0 1px 2px var(--shadow-soft), + 0 10px 24px -14px var(--shadow-strong); } .card-header { @@ -300,6 +446,10 @@ a.kpi:hover { border-bottom: 1px solid var(--color-border); } +:root[data-bs-theme="dark"] .card-header { + color: var(--color-accent-pale); +} + .table td input.form-control-sm, .table td select.form-select-sm { min-width: 8rem; @@ -320,13 +470,14 @@ a.kpi:hover { top: 0; z-index: 12; padding: 0.35rem 0; - background: #fff; - box-shadow: 0 1px 0 rgba(196, 205, 220, 0.8); + background: var(--panel-bg); + box-shadow: 0 1px 0 var(--panel-border-strong); } .editor-toolbar button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); padding: 0.28rem 0.6rem; border-radius: 4px; font-size: 0.82rem; @@ -337,8 +488,8 @@ a.kpi:hover { } .editor-toolbar .project-device-drawer-toggle { - border-color: #2563eb; - background: #2563eb; + border-color: var(--accent-blue); + background: var(--accent-blue); color: #fff; font-weight: 600; } @@ -349,10 +500,10 @@ a.kpi:hover { align-items: center; gap: 0.35rem; padding: 0.4rem 0.5rem; - border: 1px solid #bfdbfe; + border: 1px solid var(--accent-blue-border); border-radius: 5px; - background: #eff6ff; - color: #1e3a5f; + background: var(--accent-blue-soft-bg); + color: var(--text-subtle); font-size: 0.78rem; } @@ -362,10 +513,10 @@ a.kpi:hover { .active-view-chip, .active-view-reset { - border: 1px solid #93b4df; + border: 1px solid var(--accent-blue-border); border-radius: 999px; - background: #fff; - color: #1e3a5f; + background: var(--panel-bg); + color: var(--text-subtle); padding: 0.18rem 0.48rem; font-size: 0.76rem; cursor: pointer; @@ -373,7 +524,7 @@ a.kpi:hover { .active-view-chip:hover, .active-view-reset:hover { - border-color: #2563eb; + border-color: var(--accent-blue); } .active-view-reset { @@ -386,9 +537,9 @@ a.kpi:hover { grid-template-columns: repeat(3, minmax(12rem, 1fr)); gap: 0.5rem; padding: 0.55rem; - border: 1px solid #cbd5e1; + border: 1px solid var(--panel-border); border-radius: 5px; - background: #f8fafc; + background: var(--panel-bg-subtle); } .distribution-power-summary > div { @@ -398,13 +549,13 @@ a.kpi:hover { } .distribution-power-summary span { - color: #475569; + color: var(--text-subtle); font-size: 0.74rem; line-height: 1.2; } .distribution-power-summary strong { - color: #172033; + color: var(--text-strong); font-size: 0.95rem; } @@ -418,13 +569,13 @@ a.kpi:hover { position: absolute; z-index: 9; margin-top: 2rem; - border: 1px solid #cfd7e5; - background: #fff; + border: 1px solid var(--panel-border); + background: var(--panel-bg); border-radius: 5px; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + box-shadow: 0 6px 16px var(--shadow-menu); padding: 0.55rem; width: 340px; - color: #1f2937; + color: var(--text-strong); text-align: left; } @@ -444,7 +595,7 @@ a.kpi:hover { .column-settings-close { border: 0 !important; background: transparent !important; - color: #4b5563; + color: var(--text-muted); padding: 0.05rem 0.2rem !important; font-size: 1rem !important; line-height: 1; @@ -453,7 +604,7 @@ a.kpi:hover { .column-settings-explanation { margin-bottom: 0.4rem; - color: #4b5563; + color: var(--text-muted); font-size: 0.72rem; line-height: 1.3; } @@ -461,9 +612,11 @@ a.kpi:hover { .column-settings-search { width: 100%; margin-bottom: 0.4rem; - border: 1px solid #c4cddc; + border: 1px solid var(--panel-border-strong); border-radius: 4px; padding: 0.3rem 0.4rem; + background: var(--panel-bg); + color: var(--text-strong); font-size: 0.76rem; } @@ -473,7 +626,7 @@ a.kpi:hover { gap: 0.15rem; max-height: 300px; overflow: auto; - border: 1px solid #e1e6ef; + border: 1px solid var(--panel-border); border-radius: 4px; padding: 0.25rem; } @@ -489,8 +642,8 @@ a.kpi:hover { } .column-settings-item.selected { - border-color: #bfdbfe; - background: #eff6ff; + border-color: var(--accent-blue-border); + background: var(--accent-blue-soft-bg); } .column-settings-item.dragging { @@ -498,12 +651,12 @@ a.kpi:hover { } .column-settings-item.drop-target { - border-color: #2b6cb0; - background: #ebf4ff; + border-color: var(--accent-blue-drop-border); + background: var(--accent-blue-soft-bg); } .column-settings-item.locked { - background: #f7fafc; + background: var(--panel-bg-subtle); } .column-visibility-button { @@ -530,7 +683,7 @@ a.kpi:hover { flex: 0 0 1rem; align-items: center; justify-content: center; - color: #1d4ed8; + color: var(--accent-blue-marker); font-weight: 700; } @@ -540,8 +693,9 @@ a.kpi:hover { } .column-settings-order button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); border-radius: 3px; font-size: 0.72rem; padding: 0.08rem 0.28rem; @@ -549,8 +703,8 @@ a.kpi:hover { .tree-grid-wrap { overflow: auto; - border: 1px solid #d9dee8; - background: #fff; + border: 1px solid var(--panel-border); + background: var(--panel-bg); width: 100%; max-width: 100%; min-width: 0; @@ -560,7 +714,7 @@ a.kpi:hover { .column-settings-empty { padding: 0.55rem 0.35rem; - color: #6b7280; + color: var(--text-faint); font-size: 0.76rem; } @@ -570,8 +724,8 @@ a.kpi:hover { } .column-settings-footer button.primary { - border-color: #2563eb; - background: #2563eb; + border-color: var(--accent-blue); + background: var(--accent-blue); color: #fff; } @@ -583,8 +737,8 @@ a.kpi:hover { } .project-device-sidebar { - border: 1px solid #d9dee8; - background: #fff; + border: 1px solid var(--panel-border); + background: var(--panel-bg); padding: 0.6rem; display: flex; flex-direction: column; @@ -594,14 +748,17 @@ a.kpi:hover { .project-device-sidebar h3 { margin: 0; font-size: 1rem; + color: var(--text-strong); } .project-device-sidebar input, .project-device-sidebar select { width: 100%; - border: 1px solid #cfd7e5; + border: 1px solid var(--panel-border-strong); border-radius: 4px; padding: 0.25rem 0.35rem; + background: var(--panel-bg); + color: var(--text-strong); } .project-device-list { @@ -613,8 +770,9 @@ a.kpi:hover { } .project-device-item { - border: 1px solid #d5ddec; - background: #f8faff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg-subtle); + color: var(--text-strong); text-align: left; padding: 0.4rem; border-radius: 4px; @@ -625,8 +783,8 @@ a.kpi:hover { } .project-device-item.selected { - border-color: #4c7dd9; - background: #edf3ff; + border-color: var(--accent-blue-strong-border); + background: var(--accent-blue-soft-bg); } .project-device-item.dragging { @@ -645,11 +803,13 @@ a.kpi:hover { flex-direction: column; gap: 0.2rem; font-size: 0.82rem; + color: var(--text-strong); } .sidebar-actions button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); border-radius: 4px; padding: 0.3rem 0.45rem; font-size: 0.82rem; @@ -660,18 +820,19 @@ a.kpi:hover { min-width: 0; border-collapse: collapse; font-size: 0.9rem; + color: var(--text-strong); } .tree-grid th, .tree-grid td { - border: 1px solid #e4e9f2; + border: 1px solid var(--panel-border); padding: 0.35rem 0.4rem; vertical-align: middle; } .tree-grid th { width: 1px; - background: #f4f7fb; + background: var(--panel-bg-subtle); position: sticky; top: 0; z-index: 2; @@ -694,8 +855,9 @@ a.kpi:hover { .tree-grid .header-filter-btn { display: block; width: 100%; - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); border-radius: 3px; font-size: 0.75rem; padding: 0.15rem 0.35rem; @@ -712,7 +874,7 @@ a.kpi:hover { border: 0; background: transparent; padding: 0; - color: #1f2937; + color: var(--text-strong); font: inherit; font-size: 0.78rem; font-weight: 600; @@ -733,7 +895,7 @@ a.kpi:hover { max-height: calc(100vh - 7rem); overflow: auto; border-radius: 0.5rem; - box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22); + box-shadow: 0 0.75rem 2rem var(--shadow-drawer); } .project-device-drawer-header { @@ -744,13 +906,14 @@ a.kpi:hover { } .project-device-drawer-header span { - color: #6b7280; + color: var(--text-faint); font-size: 0.75rem; } .project-device-drawer-header button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); border-radius: 4px; padding: 0.25rem 0.4rem; font-size: 0.78rem; @@ -762,23 +925,24 @@ a.kpi:hover { } .tree-grid .sort-indicator { - color: #2563eb; + color: var(--accent-blue); font-size: 0.85rem; } .tree-grid .header-filter-btn.active { - border-color: #2563eb; - color: #2563eb; - background: #eff6ff; + border-color: var(--accent-blue); + color: var(--accent-blue); + background: var(--accent-blue-soft-bg); } .tree-grid .header-filter-menu { position: absolute; z-index: 7; margin-top: 0.2rem; - border: 1px solid #cfd7e5; - background: #fff; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + border: 1px solid var(--panel-border); + background: var(--panel-bg); + color: var(--text-strong); + box-shadow: 0 6px 16px var(--shadow-menu); width: 300px; padding: 0.55rem; text-align: left; @@ -802,7 +966,7 @@ a.kpi:hover { .tree-grid .header-filter-close { border: 0; background: transparent; - color: #4b5563; + color: var(--text-muted); padding: 0.05rem 0.2rem; font-size: 1rem; line-height: 1; @@ -817,13 +981,14 @@ a.kpi:hover { .tree-grid .header-filter-selection-actions span { margin-left: auto; - color: #4b5563; + color: var(--text-muted); } .tree-grid .header-filter-selection-actions button, .tree-grid .header-filter-footer button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); border-radius: 3px; padding: 0.18rem 0.35rem; font-size: 0.72rem; @@ -832,7 +997,7 @@ a.kpi:hover { .tree-grid .header-filter-explanation { margin-bottom: 0.4rem; - color: #4b5563; + color: var(--text-muted); font-size: 0.72rem; line-height: 1.3; white-space: normal; @@ -840,9 +1005,11 @@ a.kpi:hover { .tree-grid .header-filter-search { width: 100%; - border: 1px solid #c4cddc; + border: 1px solid var(--panel-border-strong); border-radius: 4px; padding: 0.3rem 0.4rem; + background: var(--panel-bg); + color: var(--text-strong); font-size: 0.76rem; } @@ -852,7 +1019,7 @@ a.kpi:hover { gap: 0.15rem; max-height: 210px; overflow: auto; - border: 1px solid #e1e6ef; + border: 1px solid var(--panel-border); border-radius: 4px; padding: 0.25rem; } @@ -866,7 +1033,7 @@ a.kpi:hover { border-radius: 3px; background: transparent; padding: 0.25rem 0.3rem; - color: #1f2937; + color: var(--text-strong); font-weight: 400; font-size: 0.75rem; text-align: left; @@ -874,17 +1041,17 @@ a.kpi:hover { } .tree-grid .header-filter-item:hover { - background: #f3f4f6; + background: var(--surface-hover); } .tree-grid .header-filter-item.selected { - border-color: #bfdbfe; - background: #eff6ff; + border-color: var(--accent-blue-border); + background: var(--accent-blue-soft-bg); } .tree-grid .header-filter-empty { padding: 0.45rem 0.25rem; - color: #6b7280; + color: var(--text-faint); font-size: 0.74rem; } @@ -897,8 +1064,8 @@ a.kpi:hover { } .tree-grid .header-filter-footer button.primary { - border-color: #2563eb; - background: #2563eb; + border-color: var(--accent-blue); + background: var(--accent-blue); color: #fff; } @@ -908,7 +1075,7 @@ a.kpi:hover { } .tree-grid .header-filter-warning { - color: #9a3412; + color: var(--grid-warn-strong); font-size: 0.7rem; white-space: normal; } @@ -918,25 +1085,25 @@ a.kpi:hover { } .tree-grid .section-row td { - background: #e8eef8; + background: var(--surface-header-row); font-weight: 600; } .tree-grid .structure-component-row td { padding: 0.38rem 0.6rem; - border-bottom-color: #d8dee9; + border-bottom-color: var(--panel-border); } .tree-grid .structure-component-row.headerComponent td { - background: #e2e8f0; + background: var(--surface-component-header); } .tree-grid .structure-component-row.groupComponent td { - background: #f8fafc; + background: var(--surface-component-group); } .tree-grid .structure-component-row.footerComponent td { - background: #f1f5f9; + background: var(--surface-component-footer); } .tree-grid .structure-component-content { @@ -952,7 +1119,7 @@ a.kpi:hover { } .tree-grid .structure-component-protection { - color: #475569; + color: var(--text-subtle); font-size: 0.78rem; } @@ -969,7 +1136,7 @@ a.kpi:hover { } .tree-grid .structure-component-fixed { - color: #64748b; + color: var(--text-faint); font-size: 0.75rem; } @@ -990,32 +1157,33 @@ a.kpi:hover { } .tree-grid .section-actions button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); padding: 0.2rem 0.45rem; border-radius: 3px; font-size: 0.78rem; } .tree-grid .summary-row td { - background: #f3f7fd; + background: var(--panel-bg-subtle); } .tree-grid .device-row td:first-child { - color: #6b7280; + color: var(--text-faint); } .tree-grid .empty-circuit-row td { - background: #f8fbff; + background: var(--panel-bg-subtle); } .tree-grid tr.row-selected td { - background: #eaf1ff; + background: var(--surface-selected); } .tree-grid .placeholder-row td { - background: #f7f7f7; - color: #6b7280; + background: var(--panel-bg-subtle); + color: var(--text-faint); font-style: italic; } @@ -1032,8 +1200,12 @@ a.kpi:hover { box-shadow 0.12s ease; } +:root[data-bs-theme="dark"] .tree-grid .cell-protection-trigger { + color: var(--color-accent-pale); +} + .tree-grid .cell-protection-trigger:hover { - background: #eaf6f0; + background: rgba(63, 166, 107, 0.15); box-shadow: inset 0 0 0 1px var(--color-signal); } @@ -1054,7 +1226,7 @@ a.kpi:hover { } .tree-grid .cell-selected { - outline: 2px solid #4c7dd9; + outline: 2px solid var(--accent-blue-strong-border); outline-offset: -2px; } @@ -1065,28 +1237,30 @@ a.kpi:hover { } .tree-grid .section-title span { - color: #475569; + color: var(--text-subtle); font-size: 0.78rem; font-weight: 500; } .tree-grid .cell-invalid { - outline: 2px solid #c2410c; + outline: 2px solid var(--grid-danger); outline-offset: -2px; - background: #fff7ed !important; + background: var(--grid-warn-bg) !important; } .tree-grid .cell-invalid input { - border-color: #c2410c; + border-color: var(--grid-danger); } .tree-grid input, .tree-grid select { width: 100%; min-width: 5rem; - border: 1px solid #9fb6e0; + border: 1px solid var(--input-border); border-radius: 2px; padding: 0.2rem 0.3rem; + background: var(--panel-bg); + color: var(--text-strong); } .tree-grid .action-cell { @@ -1095,26 +1269,27 @@ a.kpi:hover { } .tree-grid .action-cell button { - border: 1px solid #c4cddc; - background: #fff; + border: 1px solid var(--panel-border-strong); + background: var(--panel-bg); + color: var(--text-strong); padding: 0.2rem 0.45rem; border-radius: 3px; font-size: 0.78rem; } .tree-grid .drop-target-active { - box-shadow: inset 0 0 0 2px #4c7dd9; - background: #eef4ff !important; + box-shadow: inset 0 0 0 2px var(--accent-blue-strong-border); + background: var(--accent-blue-soft-bg) !important; } .tree-grid .drop-target-invalid { - box-shadow: inset 0 0 0 2px #d97706; - background: #fff7ed !important; + box-shadow: inset 0 0 0 2px var(--grid-warn); + background: var(--grid-warn-bg) !important; } .tree-grid .drop-target-confirm { - box-shadow: inset 0 0 0 2px #d97706; - background: #fffbeb !important; + box-shadow: inset 0 0 0 2px var(--grid-warn); + background: var(--grid-warn-bg) !important; } .tree-grid tr.circuit-insert-before td, @@ -1128,7 +1303,7 @@ a.kpi:hover { left: -1px; right: -1px; top: -2px; - border-top: 4px solid #2563eb; + border-top: 4px solid var(--accent-blue); pointer-events: none; } @@ -1141,7 +1316,7 @@ a.kpi:hover { height: 0; border-top: 7px solid transparent; border-bottom: 7px solid transparent; - border-left: 10px solid #2563eb; + border-left: 10px solid var(--accent-blue); pointer-events: none; } @@ -1151,7 +1326,7 @@ a.kpi:hover { left: -1px; right: -1px; bottom: -2px; - border-bottom: 4px solid #2563eb; + border-bottom: 4px solid var(--accent-blue); pointer-events: none; } @@ -1164,13 +1339,13 @@ a.kpi:hover { height: 0; border-top: 7px solid transparent; border-bottom: 7px solid transparent; - border-left: 10px solid #2563eb; + border-left: 10px solid var(--accent-blue); pointer-events: none; } .drop-hint { font-size: 0.75rem; - color: #1f4ea3; + color: var(--accent-blue-marker); font-weight: 600; } @@ -1178,21 +1353,22 @@ a.kpi:hover { padding: 0.5rem 0.75rem; border-radius: 4px; border: 1px solid transparent; + color: var(--text-strong); } .notice.info { - background: #ebf3ff; - border-color: #bad1f7; + background: var(--notice-info-bg); + border-color: var(--notice-info-border); } .notice.error { - background: #fdecec; - border-color: #f5b5b5; + background: var(--notice-error-bg); + border-color: var(--notice-error-border); } .notice.warning { - background: #fff7ed; - border-color: #fdba74; + background: var(--notice-warning-bg); + border-color: var(--notice-warning-border); } .editor-error-notice { @@ -1203,12 +1379,13 @@ a.kpi:hover { } .notice.muted { - background: #f6f6f6; - border-color: #e4e4e4; + background: var(--notice-muted-bg); + border-color: var(--notice-muted-border); + color: var(--text-muted); } .todo-hint { - color: #6b7280; + color: var(--text-faint); font-size: 0.8rem; margin: 0; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 01df63e..cd2407b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Script from "next/script"; import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; import { AppShell } from "../frontend/components/AppShell"; @@ -8,10 +9,33 @@ export const metadata: Metadata = { description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten", }; +// Keep this key literal in sync with THEME_STORAGE_KEY in ThemeToggle.tsx. +// It must stay inline (not imported) so it runs before hydration and never +// flashes the wrong theme on load. +const THEME_INIT_SCRIPT = ` +(function () { + try { + var stored = localStorage.getItem("leistungsbilanz:theme"); + var theme = + stored === "dark" || stored === "light" + ? stored + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + document.documentElement.setAttribute("data-bs-theme", theme); + } catch (error) { + // Storage/matchMedia unavailable: fall back to the default light theme. + } +})(); +`; + export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - + + {children} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index c675340..fa342ea 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -17,13 +17,13 @@ import { deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, + getProject, getProjectDeviceSyncPreview, listCircuitLists, listDistributionBoards, listFloors, listGlobalDevices, listProjectDevices, - listProjects, listRooms, importProjectTransfer, synchronizeProjectDeviceRows, @@ -129,7 +129,7 @@ export default function ProjectDetailPage() { return; } Promise.all([ - listProjects(), + getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), listFloors(projectId), @@ -138,7 +138,7 @@ export default function ProjectDetailPage() { listGlobalDevices(), ]) .then(([ - projects, + currentProject, distributionBoards, loadedCircuitLists, loadedFloors, @@ -146,7 +146,6 @@ export default function ProjectDetailPage() { loadedProjectDevices, loadedGlobalDevices, ]) => { - const currentProject = projects.find((item) => item.id === projectId) ?? null; setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -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 }); +} diff --git a/src/db/migrations/0006_damp_skrulls.sql b/src/db/migrations/0006_damp_skrulls.sql new file mode 100644 index 0000000..3f6badf --- /dev/null +++ b/src/db/migrations/0006_damp_skrulls.sql @@ -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`); \ No newline at end of file diff --git a/src/db/migrations/meta/0006_snapshot.json b/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..84f5229 --- /dev/null +++ b/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,2419 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b25e1a40-c400-4848-bc24-a04bda7a8209", + "prevId": "b79934fe-5d05-49da-b7c4-95c954e5fec2", + "tables": { + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 5a65548..eedfc7a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785687503453, "tag": "0005_stale_gorilla_man", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1786043080323, + "tag": "0006_damp_skrulls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/circuit-device-row.persistence.ts b/src/db/repositories/circuit-device-row.persistence.ts index c075f61..4a32623 100644 --- a/src/db/repositories/circuit-device-row.persistence.ts +++ b/src/db/repositories/circuit-device-row.persistence.ts @@ -43,12 +43,6 @@ export interface CircuitDeviceRowPatchInput { overriddenFields?: string | null; } -export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput { - circuitId: string; - linkedProjectDeviceId?: string; - sortOrder: number; -} - export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) { return { linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, @@ -105,15 +99,3 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput) return values; } - -export function toCircuitDeviceRowCreateValues( - id: string, - input: CircuitDeviceRowCreateInput -) { - return { - id, - circuitId: input.circuitId, - sortOrder: input.sortOrder, - ...toCircuitDeviceRowUpdateValues(input), - }; -} diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts index 6f04ffb..bbd6543 100644 --- a/src/db/repositories/circuit-project-command.repository.ts +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, ne } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { assertCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand, @@ -21,6 +21,7 @@ import { import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; type CircuitRow = typeof circuits.$inferSelect; @@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository ) { return; } - const duplicate = database - .select({ id: circuits.id }) - .from(circuits) - .where( - and( - eq(circuits.circuitListId, circuit.circuitListId), - eq(circuits.equipmentIdentifier, equipmentIdentifier), - ne(circuits.id, circuit.id) - ) - ) - .get(); - if (duplicate) { - throw new Error("Duplicate equipmentIdentifier in circuit list."); - } + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + equipmentIdentifier, + circuit.id + ); } } diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts index 454028e..9b8941d 100644 --- a/src/db/repositories/circuit-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -27,6 +27,7 @@ import { } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class CircuitStructureProjectCommandRepository implements CircuitStructureProjectCommandStore @@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository if (existingCircuit) { throw new Error("Circuit id already exists."); } - const duplicateIdentifier = database - .select({ id: circuits.id }) - .from(circuits) - .where( - and( - eq(circuits.circuitListId, snapshot.circuitListId), - eq( - circuits.equipmentIdentifier, - snapshot.equipmentIdentifier - ) - ) - ) - .get(); - if (duplicateIdentifier) { - throw new Error( - "Duplicate equipmentIdentifier in circuit list." - ); - } + assertEquipmentIdentifierAvailable( + database, + snapshot.circuitListId, + snapshot.equipmentIdentifier + ); if (snapshot.deviceRows.length > 0) { const rowIds = snapshot.deviceRows.map((row) => row.id); diff --git a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts index 2dc3bba..3ea4291 100644 --- a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts @@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class DistributionBoardComponentStructureProjectCommandRepository implements DistributionBoardComponentStructureProjectCommandStore @@ -94,6 +95,11 @@ export class DistributionBoardComponentStructureProjectCommandRepository if (existing) { throw new Error("Distribution-board component id already exists."); } + assertEquipmentIdentifierAvailable( + database, + snapshot.component.circuitListId, + snapshot.component.equipmentIdentifier + ); database .insert(distributionBoardComponents) .values(snapshot.component) @@ -187,6 +193,17 @@ export class DistributionBoardComponentStructureProjectCommandRepository "Distribution-board component changed before update." ); } + if ( + target.component.equipmentIdentifier !== + expected.component.equipmentIdentifier + ) { + assertEquipmentIdentifierAvailable( + database, + expected.component.circuitListId, + target.component.equipmentIdentifier, + expected.component.id + ); + } database .update(distributionBoardComponents) .set(target.component) diff --git a/src/db/repositories/equipment-identifier-uniqueness.persistence.ts b/src/db/repositories/equipment-identifier-uniqueness.persistence.ts new file mode 100644 index 0000000..43b63ad --- /dev/null +++ b/src/db/repositories/equipment-identifier-uniqueness.persistence.ts @@ -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."); + } +} diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts index 9d2895a..1f2281e 100644 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -212,7 +212,9 @@ function replaceExternalState( if (target.roomMappings.length) { database.insert(externalRoomMappings).values(target.roomMappings).run(); } - database.insert(externalModelObjects).values(target.objects).run(); + if (target.objects.length) { + database.insert(externalModelObjects).values(target.objects).run(); + } } function decodeCanonicalBase64(value: string) { diff --git a/src/db/repositories/external-object-new-circuit-project-command.repository.ts b/src/db/repositories/external-object-new-circuit-project-command.repository.ts index c25ac76..bea64ff 100644 --- a/src/db/repositories/external-object-new-circuit-project-command.repository.ts +++ b/src/db/repositories/external-object-new-circuit-project-command.repository.ts @@ -32,6 +32,7 @@ import { loadExpectedExternalObjectTransitions, snapshotsEqual, } from "./external-object-assignment.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class ExternalObjectNewCircuitProjectCommandRepository implements ExternalObjectNewCircuitProjectCommandStore @@ -90,12 +91,11 @@ export class ExternalObjectNewCircuitProjectCommandRepository .where(eq(circuits.id, circuit.id)).get()) { throw new Error("External circuit id already exists."); } - if (database.select({ id: circuits.id }).from(circuits).where(and( - eq(circuits.circuitListId, circuit.circuitListId), - eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier) - )).get()) { - throw new Error("Duplicate equipmentIdentifier in circuit list."); - } + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + circuit.equipmentIdentifier + ); if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, row.id)).get()) { throw new Error("External device-row id already exists."); diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index da17458..eb722d7 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { + const values: typeof assignment.target & { + manualQuantity?: number; + } = { ...assignment.target }; + if (assignment.target.quantity !== assignment.expected.quantity) { + const externalTotal = tx + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where( + eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) + ) + .all() + .reduce( + (sum, object) => sum + object.planningValues.effectiveQuantity, + 0 + ); + const manualQuantity = assignment.target.quantity - externalTotal; + if (manualQuantity < 0) { + throw new Error( + "Synchronized quantity is below the total quantity of linked external objects." + ); + } + values.manualQuantity = manualQuantity; + } const updated = tx .update(circuitDeviceRows) - .set(assignment.target) + .set(values) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/src/db/schema/circuit-device-rows.ts b/src/db/schema/circuit-device-rows.ts index a5da01e..f718c5d 100644 --- a/src/db/schema/circuit-device-rows.ts +++ b/src/db/schema/circuit-device-rows.ts @@ -1,35 +1,38 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { circuits } from "./circuits.js"; import { projectDevices } from "./project-devices.js"; import { rooms } from "./rooms.js"; -export const circuitDeviceRows = sqliteTable("circuit_device_rows", { - id: text("id").primaryKey(), - circuitId: text("circuit_id") - .notNull() - .references(() => circuits.id, { onDelete: "cascade" }), - linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { - onDelete: "set null", - }), - sortOrder: integer("sort_order").notNull().default(0), - name: text("name").notNull(), - displayName: text("display_name").notNull(), - phaseType: text("phase_type"), - connectionKind: text("connection_kind"), - costGroup: text("cost_group"), - category: text("category"), - level: text("level"), - roomId: text("room_id").references(() => rooms.id, { - onDelete: "set null", - }), - roomNumberSnapshot: text("room_number_snapshot"), - roomNameSnapshot: text("room_name_snapshot"), - quantity: integer("quantity").notNull(), - manualQuantity: integer("manual_quantity").notNull().default(0), - powerPerUnit: real("power_per_unit").notNull(), - simultaneityFactor: real("simultaneity_factor").notNull(), - cosPhi: real("cos_phi"), - remark: text("remark"), - overriddenFields: text("overridden_fields"), -}); - +export const circuitDeviceRows = sqliteTable( + "circuit_device_rows", + { + id: text("id").primaryKey(), + circuitId: text("circuit_id") + .notNull() + .references(() => circuits.id, { onDelete: "cascade" }), + linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { + onDelete: "set null", + }), + sortOrder: integer("sort_order").notNull().default(0), + name: text("name").notNull(), + displayName: text("display_name").notNull(), + phaseType: text("phase_type"), + connectionKind: text("connection_kind"), + costGroup: text("cost_group"), + category: text("category"), + level: text("level"), + roomId: text("room_id").references(() => rooms.id, { + onDelete: "set null", + }), + roomNumberSnapshot: text("room_number_snapshot"), + roomNameSnapshot: text("room_name_snapshot"), + quantity: integer("quantity").notNull(), + manualQuantity: integer("manual_quantity").notNull().default(0), + powerPerUnit: real("power_per_unit").notNull(), + simultaneityFactor: real("simultaneity_factor").notNull(), + cosPhi: real("cos_phi"), + remark: text("remark"), + overriddenFields: text("overridden_fields"), + }, + (table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)] +); diff --git a/src/db/schema/circuits.ts b/src/db/schema/circuits.ts index c0c733c..6e3ed3e 100644 --- a/src/db/schema/circuits.ts +++ b/src/db/schema/circuits.ts @@ -1,4 +1,4 @@ -import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { circuitLists } from "./circuit-lists.js"; import { circuitSections } from "./circuit-sections.js"; @@ -26,6 +26,9 @@ export const circuits = sqliteTable( isReserve: integer("is_reserve").notNull().default(0), remark: text("remark"), }, - (table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)] + (table) => [ + unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier), + index("circuits_section_id_idx").on(table.sectionId), + ] ); diff --git a/src/domain/models/circuit-device-row-project-command.model.ts b/src/domain/models/circuit-device-row-project-command.model.ts index 825701f..8e949a1 100644 --- a/src/domain/models/circuit-device-row-project-command.model.ts +++ b/src/domain/models/circuit-device-row-project-command.model.ts @@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue( if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw new Error(`${field} must be a non-negative finite number.`); } + if (field === "simultaneityFactor" && value > 1) { + throw new Error("simultaneityFactor must not exceed 1."); + } return; } if (field === "cosPhi") { diff --git a/src/domain/models/circuit-device-row-structure-project-command.model.ts b/src/domain/models/circuit-device-row-structure-project-command.model.ts index d6e3cf6..2464a27 100644 --- a/src/domain/models/circuit-device-row-structure-project-command.model.ts +++ b/src/domain/models/circuit-device-row-structure-project-command.model.ts @@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand( row.simultaneityFactor, "row.simultaneityFactor" ); + if (row.simultaneityFactor > 1) { + throw new Error("row.simultaneityFactor must not exceed 1."); + } if (row.cosPhi !== null) { assertFiniteNumber(row.cosPhi, "row.cosPhi"); if (row.cosPhi <= 0) { diff --git a/src/domain/models/circuit-device-row.model.ts b/src/domain/models/circuit-device-row.model.ts deleted file mode 100644 index f18c63c..0000000 --- a/src/domain/models/circuit-device-row.model.ts +++ /dev/null @@ -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; -} - diff --git a/src/domain/models/circuit-protection-project-command.model.ts b/src/domain/models/circuit-protection-project-command.model.ts index 2e8e6f4..0c8a19e 100644 --- a/src/domain/models/circuit-protection-project-command.model.ts +++ b/src/domain/models/circuit-protection-project-command.model.ts @@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand( if (target !== null) { assertCircuitProtectionSnapshot(target, circuitId); } - if (JSON.stringify(expected) === JSON.stringify(target)) { + if (circuitProtectionSnapshotsEqual(expected, target)) { throw new Error("Circuit protection update must change state."); } } +function circuitProtectionSnapshotsEqual( + left: CircuitProtectionSnapshot | null, + right: CircuitProtectionSnapshot | null +): boolean { + if (left === null || right === null) { + return left === right; + } + return ( + left.circuitId === right.circuitId && + left.type === right.type && + left.ratedCurrentA === right.ratedCurrentA && + left.fuseUtilizationCategory === right.fuseUtilizationCategory && + left.tripCharacteristic === right.tripCharacteristic && + left.rcdType === right.rcdType && + left.ratedResidualCurrentMa === right.ratedResidualCurrentMa + ); +} + export function assertCircuitProtectionSnapshot( value: unknown, circuitId: string -) { +): asserts value is CircuitProtectionSnapshot { if ( !isPlainObject(value) || Object.keys(value).length !== 7 || diff --git a/src/domain/models/circuit-section.model.ts b/src/domain/models/circuit-section.model.ts deleted file mode 100644 index bba2c4e..0000000 --- a/src/domain/models/circuit-section.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CircuitSection { - id: string; - circuitListId: string; - key: string; - displayName: string; - prefix: string; - sortOrder: number; -} - diff --git a/src/domain/models/circuit.model.ts b/src/domain/models/circuit.model.ts deleted file mode 100644 index 3efe12c..0000000 --- a/src/domain/models/circuit.model.ts +++ /dev/null @@ -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; -} - diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 060c795..7172cb7 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess( quantity: finiteNumberSchema.nonnegative(), manualQuantity: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(), - simultaneityFactor: finiteNumberSchema.nonnegative(), + simultaneityFactor: finiteNumberSchema.min(0).max(1), cosPhi: finiteNumberSchema.positive().nullable(), remark: nullableStringSchema, overriddenFields: nullableStringSchema, diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index f58c4f6..b3cbb4f 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; +import { ThemeToggle } from "./ThemeToggle"; const PROJECT_SECTIONS = [ { id: "verlauf", label: "Verlauf", icon: "↺" }, @@ -135,6 +136,9 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { )} +
+ +
); } diff --git a/src/frontend/components/ThemeToggle.tsx b/src/frontend/components/ThemeToggle.tsx new file mode 100644 index 0000000..355e0e2 --- /dev/null +++ b/src/frontend/components/ThemeToggle.tsx @@ -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("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 ( + + ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index b63cd8e..03aa38d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -268,6 +268,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); + // Synchronous re-entry guard: isSaving is React state and only reflects + // reality after the next render, so a second click/drop fired within the + // same tick could otherwise race past it and double-submit a command. + const commandInFlightRef = useRef(false); const [componentEditorIntent, setComponentEditorIntent] = useState(null); const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = @@ -723,6 +727,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); }, [data]); + // Clears the sidebar's target selection once it no longer resolves to a + // real section/circuit (e.g. deleted, moved or renumbered elsewhere) + // instead of silently holding a stale id after a tree reload. + useEffect(() => { + if (!data) { + return; + } + if ( + targetSectionId && + !data.sections.some((section) => section.id === targetSectionId) + ) { + setTargetSectionId(null); + } + if ( + targetCircuitId && + !circuitOptions.some((option) => option.id === targetCircuitId) + ) { + setTargetCircuitId(null); + } + }, [data, circuitOptions, targetSectionId, targetCircuitId]); + const allCircuits = useMemo( () => data?.sections.flatMap((section) => section.circuits) ?? [], [data] @@ -1046,6 +1071,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Runs a normal command. The server records it in project-wide history. async function runCommand(command: HistoryCommand) { + if (commandInFlightRef.current) { + return; + } + commandInFlightRef.current = true; try { setError(null); setIsSaving(true); @@ -1056,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await loadTree({ showLoading: false }); setError(message); } finally { + commandInFlightRef.current = false; setIsSaving(false); } } @@ -1063,6 +1093,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Applies the next eligible project-wide history operation. Selection is only // a best-effort local hint; command eligibility and data changes stay server-owned. async function applyHistory(mode: "undo" | "redo") { + if (commandInFlightRef.current) { + return; + } + commandInFlightRef.current = true; try { setError(null); setHistoryBusy(true); @@ -1088,6 +1122,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await loadTree({ showLoading: false }); setError(message); } finally { + commandInFlightRef.current = false; setIsSaving(false); setHistoryBusy(false); } @@ -1744,7 +1779,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!section) { throw new Error("Bereich wurde nicht gefunden."); } - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const isDeviceField = deviceFieldKeys.has(key); @@ -1933,7 +1968,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await runCommand({ label: "Stromkreis hinzufügen", redo: async () => { - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const circuit = createCircuitSnapshot({ sectionId, @@ -2127,7 +2162,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!section) { throw new Error("Der Zielbereich ist ungültig."); } - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const circuit = createCircuitSnapshot( @@ -2538,7 +2573,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await runCommand({ label: newCircuitLabel, redo: async () => { - const next = await getNextCircuitIdentifier(intent.sectionId); + const next = await getNextCircuitIdentifier(projectId, intent.sectionId); const sortOrder = intent.targetCircuitId && intent.placement ? getAdjacentInsertionSortOrder( @@ -3699,6 +3734,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || !buildCircuitGroupReorderAssignments( data.sections, section.id, @@ -3716,6 +3752,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || !buildCircuitGroupReorderAssignments( data.sections, section.id, @@ -3733,6 +3770,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || hasActiveSortOrFilter || !section.category || !canRenumberCircuitGroups( @@ -3758,6 +3796,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - ) : null} diff --git a/src/frontend/components/form-modal.tsx b/src/frontend/components/form-modal.tsx index 05e18eb..d893690 100644 --- a/src/frontend/components/form-modal.tsx +++ b/src/frontend/components/form-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { type FormEvent, type ReactNode } from "react"; +import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react"; interface FormModalProps { children: ReactNode; @@ -14,6 +14,9 @@ interface FormModalProps { title: string; } +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + export function FormModal({ children, description, @@ -25,11 +28,56 @@ export function FormModal({ submitLabel, title, }: FormModalProps) { + const dialogRef = useRef(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(FOCUSABLE_SELECTOR); + firstFocusable?.focus(); + return () => { + previouslyFocused?.focus(); + }; + }, []); + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + if (!isSaving) { + event.stopPropagation(); + onClose(); + } + return; + } + if (event.key !== "Tab" || !dialogRef.current) { + return; + } + const focusable = Array.from( + dialogRef.current.querySelectorAll(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 ( <>
diff --git a/src/frontend/components/project-settings-modal.tsx b/src/frontend/components/project-settings-modal.tsx index 77295df..4af751c 100644 --- a/src/frontend/components/project-settings-modal.tsx +++ b/src/frontend/components/project-settings-modal.tsx @@ -7,6 +7,7 @@ import { distributionBoardSupplyTypes, type DistributionBoardSupplyType, } from "../../shared/constants/distribution-board"; +import { FormModal } from "./form-modal"; export interface ProjectSettingsInput { name: string; @@ -131,319 +132,278 @@ export function ProjectSettingsModal({ } return ( - <> -
-
-
-
-
-

- Projekteinstellungen -

-

- Stammdaten und elektrische Standardwerte des Projekts -

-
-
-
-
-
- - setName(event.target.value)} - required - value={name} - /> -
-
- - - setInternalProjectNumber(event.target.value) - } - value={internalProjectNumber} - /> -
-
- - - setExternalProjectNumber(event.target.value) - } - value={externalProjectNumber} - /> -
-
-
- - Verwendete Netzarten - -

- Nur ausgewählte Netzarten stehen bei Verteilungen zur - Auswahl. Bereits verwendete Netzarten können nicht - deaktiviert werden. -

-
- {distributionBoardSupplyTypes.map((supplyType) => ( -
- -
- ))} -
- {enabledDistributionBoardSupplyTypes.length === 0 ? ( -
- Mindestens eine Netzart muss aktiviert sein. -
- ) : null} -
-
-
- - setBuildingOwner(event.target.value)} - value={buildingOwner} - /> -
-
- -