diff --git a/.dockerignore b/.dockerignore index 69951f8..80054d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ .git -.agents -.codex .next -data dist node_modules -npm-debug.log* +data +*.db +.env +.env.* 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/AGENTS.md b/AGENTS.md index fdee43a..f1a509d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,17 +33,12 @@ It must support circuits, device rows, project devices, drag-and-drop restructur - General application repositories also require an explicit `AppDatabase`; `src/server/composition/application-repositories.ts` owns their runtime instances, and controllers never import the global SQLite client. -- The upgrade-only legacy migration service follows the same dependency rule; - its concrete repositories are instantiated only in - `scripts/db-migrate-legacy-consumers.ts`. - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard repositories expose only active reads. Runtime writes belong in typed command repositories; direct integration fixtures belong under `tests/support`. -The supported runtime model is Circuit-First. The former Consumer UI and API are -removed. Retained `consumers` rows, migration mappings and reports are upgrade-only -data accessed by `npm run db:migrate:legacy-consumers`; do not build application -features on them. +The supported runtime model is Circuit-First. The former Consumer UI, API, +tables and upgrade tooling are removed; do not reintroduce them. See `docs/current-architecture.md` for the complete module and request flow. @@ -250,16 +245,97 @@ Immutable revision metadata is available through the paginated stored command payloads. Named logical snapshots can be created and listed through project-scoped API endpoints. Their schema-versioned payload contains the complete supported -project runtime state and a SHA-256, excludes global/upgrade-only data and does +project runtime state and a SHA-256, excludes global data outside the project and does not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Snapshot schema version 7 also contains circuit-group identity, -distribution-board components and the separate circuit/component protection -records. Versions 1 through 6 remain readable. Portable duplicate imports +Current snapshot schema version 5 additionally stores the manual quantity share +of every CircuitDeviceRow. Version 4 snapshots are upgraded with +`manualQuantity = quantity`; they already contain the complete confirmed +external-model source, import batches, room mappings and objects. Version 3 +snapshots are upgraded with an empty external-model state; version 2 snapshots +are additionally upgraded with an empty external CSV configuration. Supported +baseline version 1 snapshots are additionally upgraded +with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All +supported versions contain circuit-group identity, distribution-board components and the +separate circuit/component protection records. Portable duplicate imports remap component ids and protection-owner references together with the existing project graph. +Project-wide Revit CSV configuration uses +`external-csv-configuration.update`. Its complete expected/target snapshot is +persisted through the shared project-command transaction, so create, update, +delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable +project duplication preserve the configuration; duplication remaps its internal +id and project link. +The project page exposes the Revit CSV modal. It edits the complete +transport/column/family-rule configuration through the revision-safe endpoint, +requests a stateless preview and then shows the Phase 14.2 initial-import +decisions. Preview responses include all mapped objects, while the UI renders +only the first 25. No preview or plan creates external objects, rows, revisions +or server-side drafts. +Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, +immutable import batches with original bytes plus the classified cell matrix, +normalized source-room mappings and external objects unique by +`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. +The general external-model repository is read-only; runtime writes belong only +to typed command adapters. Snapshot/restore and portable project transfer schema version 5 +capture the complete external state and remap every internal UUID and link while +preserving IFCGUIDs, source values, original bytes and the classified matrix. +Import batches additionally store the monotonic project CSV configuration +version introduced by additive migration `0004`; it is distinct from the CSV +configuration JSON schema version. +Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills +existing rows from `quantity` and keeps `quantity` as the materialized total. +Normal manual row inserts and quantity edits update both values together. +`external-object.update-row-assignment` atomically assigns external objects to +existing rows, moves them between existing rows or detaches them. It preserves +the manual quantity, validates the materialized total against every target +link, rejects project/board/category/room/selection-marker mixing and requires +explicitly listed confirmations for other planning-value conflicts. Its exact +inverse supports persistent Undo/Redo, and detach never deletes the row. +`external-object.assign-to-new-row` atomically inserts unassigned external +objects as one external-only row (`manualQuantity = 0`) into an existing +circuit. Its history-only inverse removes the row only while its complete +snapshot and exact object-link set remain unchanged. Ordinary detach continues +to preserve the row. +`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit +with stable BMK/UUIDs, derived voltage, one external-only row, required default +protection and its external object links. Its history-only inverse requires the +complete unchanged circuit snapshot and exact link set. Redo never recalculates +the BMK. All three external assignment stores share +`external-object-assignment.persistence.ts` for compatibility and link safety. +The read-only +`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` +projection resolves the board through the owned CircuitList and returns only +that board's compact external objects, unassigned first. It exposes no original +CSV bytes or command payloads and returns an empty list before optional import. +Confirmed initial state is written only through +`external-import.apply-initial`. The command rechecks configuration version, +original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit +planning overrides and internal project links in the shared transaction. It +never creates or links a CircuitDeviceRow. Its exact inverse removes the whole +unchanged external state, and Redo restores the same UUIDs and bytes. +`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless +read path for the first wizard stage. It rejects projects with an existing +external source and returns source-room groups, exact room-number suggestions, +exact family/type groups, projected object values, issue counts and the current +floor/room/board/ProjectDevice catalogs. It creates no draft, revision or +domain row. The wide modal renders these groups and catalogs as explicit +decisions and identifies exact, ambiguous and new room matches. Missing rooms +and ProjectDevices are created through the reused project forms with mapped +Revit values as visible defaults. Each catalog creation remains its own project +command; the wizard adds and selects the result without discarding its plan. +The dedicated +`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint +retransmits and replans the file against the expected hash, configuration +version and project revision. It requires one explicit decision per source-room +and exact family/type group, blocks unclassified families, creates all stable +external UUIDs server-side and invokes `external-import.apply-initial`. +Room/default-board and optional ProjectDevice links are supported; CSV circuit +values remain source-only and every `circuitDeviceRowId` remains null. The UI +blocks unclassified families, requires a final confirmation and applies the +whole import as one persistent Undo/Redo step. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. @@ -289,18 +365,25 @@ global-to-project copy API/UI paths use these persistent commands and track the returned project revision. ProjectDevice synchronization/disconnect API and UI paths do the same; their undo action uses the project-wide history endpoint. Project settings use the persistent `project.update-settings` command. Project -metadata, both voltage defaults and the enabled distribution-board supply types -change in one revision and Undo/Redo restores them together; the project PUT -route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`, +metadata, the public-building classification, both voltage defaults and the +enabled distribution-board supply types change in one revision and Undo/Redo +restores them together; the project PUT route requires `expectedRevision`. +The public-building flag is the future sizing input for requiring halogen-free +cables and lines. The system catalog is `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board cannot be disabled. -Distribution-board setup uses `distribution-board.insert` schema version 3 +Distribution-board setup uses `distribution-board.insert` schema version 1 with a complete stable snapshot of the board, circuit list, three default groups, main switch `-Q0` and surge protective device `-FA`. Its inverse removes only the same unchanged and still-empty structure; the POST route requires `expectedRevision` and returns the updated history state. -Stored schema-version 1 and 2 setup commands remain executable with their four -legacy sections and without invented components. +Complete populated distribution-board copying and deletion use +`distribution-board.insert-subtree` and `distribution-board.delete-subtree`. +Their exact snapshots include the board, circuit list, groups, circuits, +device rows, components and all one-to-one protection data. Copy remaps all +owning ids but preserves project-device and room links. Both operations are +atomic persistent commands with restart-safe Undo/Redo; deletion requires an +explicit UI warning. Mutable group-protection and auxiliary distribution-board components use `distribution-board-component.insert` and `distribution-board-component.delete`; edits and footer reordering use @@ -335,15 +418,15 @@ UUID in foreign-key-safe order. The circuit-tree read model exposes fixed `headerComponents`, group-owned `components`, `footerComponents`, optional group category/number and optional one-to-one protection DTOs for circuits and group components. These are -additive to the retained flat circuit protection fields during the transition. +the only supported circuit-protection source; no flat compatibility fields remain. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. -Floor and room setup use `project-floor.insert` and `project-room.insert` with -complete stable snapshots. Their inverses remove only unchanged records and +Floor and room setup uses persistent insert, update and delete commands with +complete stable snapshots. Their inverses remove or restore only unchanged records and reject floors with assigned rooms/distribution boards or rooms referenced by -device rows or retained upgrade data. Both POST routes require `expectedRevision` and return -the updated history state. +device rows. All write routes require `expectedRevision` and return the updated +history state. Required operations: @@ -359,6 +442,60 @@ Required operations: - edit equipment identifier - synchronization changes + +## Revit / CSV External Model Work + +When explicitly working on Phase 14, use +`docs/spec/revit-csv-integration-requirements.md` as the detailed source of +truth together with the current architecture documents. + +Critical rules: + +- Revit integration is optional. Do not make imported external data mandatory + for projects, rooms, project devices, circuits or device rows. +- Parse imports into staging first. Never write CSV rows directly into circuits + or device rows. +- Keep external object identity separate from internal UUIDs and circuit BMKs. + `IfcGUID` identifies the external Revit object and must never replace an + internal UUID or `equipmentIdentifier`. +- One imported Revit object may exist without a `CircuitDeviceRow`. + Import, room assignment, distribution-board assignment, classification and + ProjectDevice linking do not create a row. +- Create or change a `CircuitDeviceRow` only after an explicit user action in + the circuit-list editor. +- Several external objects may link to one `CircuitDeviceRow`, while every + external object remains individually traceable for follow-up imports and + object-specific export. +- Multi-socket Revit objects are indivisible. Their effective quantity may be + greater than one, but one external object must never be split across circuits. +- Existing ProjectDevices are canonical project templates and must not be + overwritten from Revit values. When creating a new ProjectDevice, mapped + Revit values may prefill the creation form. +- Store imported source values separately from local planning values and local + overrides. Imports never silently overwrite local values. +- Repeated imports match deterministically by project/source and `IfcGUID`. + New, changed, missing and conflicting objects require preview and explicit + user decisions. +- The first implementation assumes one complete Revit model export per project. + Do not add visible multi-source, linked-model or partial-import management. +- CSV configuration is project-scoped. Preserve title, header, blank, subtotal + and unknown rows and columns for the return export. +- Treat rows without `IfcGUID` as passthrough when they contain no object data. + Do not report Revit subtotal rows as missing-identity objects. +- Apply each confirmed import diff through a typed project command with + `expectedRevision`, complete inverse data and the shared project command + transaction boundary. +- External-model state must be added to logical project snapshots and portable + project transfer when it becomes part of the supported runtime. +- Keep CSV parsing/serialization in a transport adapter. Domain services must + not depend on CSV syntax, React, `better-sqlite3` or concrete repositories. +- Do not implement automatic circuit planning, Dynamo/API exchange, + PostgreSQL, background jobs or permissions as part of the initial CSV phase. + +Before implementation, audit the current ProjectDevice, CircuitDeviceRow, +command, snapshot, transfer and editor drag-and-drop paths. Propose concrete +domain objects and commands before adding migrations. + ## Future Sizing Do not implement full cable/protection sizing unless explicitly requested. @@ -382,7 +519,10 @@ Users must be able to override sizing suggestions. ## Persistence and Migration Rules - SQLite is the currently supported database. -- Never edit an already applied migration. +- Migration `0000` is the clean release baseline for empty databases. +- Pre-baseline development databases, snapshots and stored commands are not + supported. +- After the first release, never edit an already applied migration. - Back up an existing database before applying a new migration. - Inspect generated SQL; it must contain only the intended schema change. - Keep database backups separate from logical project snapshots. @@ -393,7 +533,8 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- Revit/CSV/IFCGUID round-trip +- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and + `docs/spec/revit-csv-integration-requirements.md` is being followed - full electrical sizing - multi-user/PostgreSQL operation - supported production deployment diff --git a/Dockerfile b/Dockerfile index 70e7761..31d28c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ -FROM node:22-bookworm-slim - +FROM node:24 WORKDIR /app -ENV NEXT_TELEMETRY_DISABLED=1 - -COPY package.json package-lock.json ./ +COPY package*.json ./ RUN npm ci COPY . . -EXPOSE 3000 3001 +# 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 + +EXPOSE 3001 + +CMD ["sh", "scripts/docker-start.sh"] diff --git a/README.md b/README.md index 520edd2..fc82fca 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ implementiert. ## Unterstützter Arbeitsablauf - Projekte, Verteilungen, Etagen und Räume verwalten -- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten +- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und + 3-phasige Stromkreise verwalten +- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche + Verteilergeräte in der Stromkreisliste abbilden +- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren - leere, einzeilige und mehrzeilige Stromkreise abbilden - Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren - Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren @@ -58,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 @@ -90,6 +108,7 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup. npm test npm run build:api npm run build:web +npm run typecheck:scripts npx tsc --noEmit -p tsconfig.next.json ``` @@ -103,11 +122,10 @@ npm run db:generate ``` `db:backup` verwendet die SQLite-Online-Backup-API und prüft das Ergebnis auf -Integrität und Fremdschlüsselverletzungen. Vor jeder Migration einer bestehenden -Datenbank ist ein Backup erforderlich. - -`db:migrate:legacy-consumers` und `db:backfill:sections` sind ausschließlich -Upgrade-Werkzeuge für ältere Datenbanken. Neue Installationen benötigen sie nicht. +Integrität und Fremdschlüsselverletzungen. Migration `0000` ist die +Release-Baseline für eine leere Datenbank; nachfolgende nummerierte Migrationen +entwickeln diese Baseline additiv weiter. Datenbanken aus Entwicklungsständen +vor der Baseline werden nicht unterstützt. ## Dokumentation 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/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db b/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db deleted file mode 100644 index c803dba..0000000 Binary files a/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db and /dev/null differ diff --git a/docs/README.md b/docs/README.md index 652a097..69a2115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,14 +20,13 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Deployment und Betrieb](deployment.md) -- [Lokale Circuit-First-Datenbankmigration](local-db-circuit-first-migration.md) -- [Retained Legacy-Datenmigration](circuit-list-editor-migration.md) – - ausschließlich für Upgrades alter Datenbanken ## Zukunftsarchitektur - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md) +- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) +- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md) - [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md) - [Roadmap und Phasen](spec/07-implementation-phases-todo.md) - [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md) diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 2062d1b..9ed9e0a 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -59,15 +59,24 @@ this value for optimistic concurrency checks. - clients cannot submit arbitrary `project.restore-state` payloads through the generic command endpoint -The public dispatcher currently supports `circuit.update`, `circuit.insert`, -`circuit.delete`, `circuit-device-row.update`, -`circuit-device-row.insert`, `circuit-device-row.delete`, -`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, -`circuit.reorder-section`, `circuit.reorder-sections`, -`circuit.renumber-section` and -`project-device.update`, `project-device.insert`, `project-device.delete` and -`project-device.sync-rows` as well as `project.update-settings`, all with -schema version `1`. Other command types +The public dispatcher supports the Circuit and CircuitDeviceRow commands +`circuit.update`, `circuit.insert`, `circuit.delete`, +`circuit-device-row.update`, `circuit-device-row.insert`, +`circuit-device-row.delete`, `circuit-device-row.move`, +`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`, +`circuit.reorder-sections` and `circuit.renumber-section`. + +Protected-board structure uses +`distribution-board-component.insert|update|delete`, +`circuit-group.insert|update|delete|reorder|renumber`, +`circuit.move-group`, `circuit-group.delete-subtree` and +`circuit-protection.update`. A subtree restore is normally emitted only as the +persisted inverse of a delete. The dispatcher also supports the documented +project-device, project settings, distribution-board and project-location +commands. The generic editor commands listed here use schema version `1`. +`project.update-settings` uses version `2` and still accepts supported baseline +version `1` commands without changing the newer building classification. +Pre-baseline formats remain unsupported. Unsupported types or schema versions are rejected. Insert commands contain the complete entity or circuit block with stable ids; delete commands include the expected parent identity. Circuit snapshots contain zero, one or multiple complete device rows. Move commands @@ -101,6 +110,24 @@ targets occupied by other sections, then applies swaps through collision-safe temporary identifiers. Undo restores the exact prior identifiers; sort positions and device rows remain unchanged. +`circuit-group.reorder` changes only complete group sort assignments. +`circuit-group.renumber` carries every affected group, child circuit and group +component with expected and target identifiers so swaps can be applied +collision-safely. `circuit.move-group` changes one complete circuit's group, +BMK and position and stores the deterministic target BMK. The target group must +have the same category. + +`circuit-group.delete-subtree` carries the complete warned group state, +including both protection layers and every device-row link/override value. The +server captures the current subtree again inside the transaction and rejects a +stale or incomplete snapshot. Undo restores the same UUIDs through the stored +inverse command. + +`circuit-protection.update` inserts or replaces the complete validated +one-to-one circuit protection state. Users cannot remove the required +protection row; removal is available only to a stored inverse when undoing its +initial creation. + `project-device.sync-rows` represents `synchronize`, `disconnect` and `reconnect` operations. Every selected row carries complete expected and target snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`. @@ -145,12 +172,12 @@ synchronizes linked circuit rows implicitly. - `PUT /projects/:projectId` - request: - `{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` + `{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` - executes `project.update-settings` as one atomic revision - response: `{ "project": { ... }, "revision": { ... }, "history": { ... } }` - - persistent Undo/Redo restores metadata, voltage values and the enabled - distribution-board supply types together; project-device and circuit + - persistent Undo/Redo restores metadata, the public-building flag, voltage + values and enabled distribution-board supply types together; project-device and circuit voltages are recalculated from the restored project settings in the same transaction - project-device and circuit voltage are derived values and are not accepted @@ -200,14 +227,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. `{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }` - persistent Undo removes only the unchanged and still-empty generated structure; Redo restores the same ids - - stored command schema versions 1 and 2 remain executable with their four - legacy sections and without generated components - stale revisions return `409 PROJECT_REVISION_CONFLICT` - `PUT /projects/:projectId/distribution-boards/:distributionBoardId` - body: `{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }` - executes `distribution-board.update`; floor and supply type are restored together by persistent Undo/Redo +- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` + - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` + - duplicates the complete distribution-board subtree with new owning UUIDs + while preserving project-device and room links + - response includes the new `distributionBoard`, its `circuitList`, the + revision and current history state +- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId` + - body: `{ "expectedRevision": 15 }` + - removes the complete, exactly captured distribution-board subtree in one + transaction + - response includes `distributionBoardId`, the revision and current history + state +- Copy and deletion use `distribution-board.insert-subtree` and + `distribution-board.delete-subtree`. Persistent Undo/Redo restores or + removes the same complete subtree, including protection devices. ### Project Floors and Rooms @@ -215,16 +255,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. - body: `{ "name": "EG", "expectedRevision": 13 }` - executes `project-floor.insert` with a stable floor id - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` +- `PUT /projects/:projectId/floors/:floorId` + - body: `{ "name": "1. OG", "expectedRevision": 14 }` + - executes an exact persistent floor update +- `DELETE /projects/:projectId/floors/:floorId` + - body: `{ "expectedRevision": 15 }` + - rejects floors with assigned rooms or distribution boards - `POST /projects/:projectId/rooms` - body: `{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }` - executes `project-room.insert` with a stable room id; `floorId` is optional and must belong to the project when present - response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }` -- Persistent Undo removes only unchanged, unreferenced records. A floor with - assigned rooms or distribution boards and a room referenced by device rows - or retained upgrade data are rejected instead of silently clearing foreign - keys. +- `PUT /projects/:projectId/rooms/:roomId` + - body: + `{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }` + - updates number, name and optional floor as one persistent command +- `DELETE /projects/:projectId/rooms/:roomId` + - body: `{ "expectedRevision": 17 }` + - rejects rooms referenced by device rows +- Persistent Undo/Redo restores exact previous and target records without + silently clearing foreign keys. - Stale revisions return `409 PROJECT_REVISION_CONFLICT`. ### Tree Endpoint @@ -308,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 @@ -318,12 +370,6 @@ available only as the corresponding versioned commands through structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or CircuitDeviceRow DELETE routes. -## Removed Legacy Endpoints - -The former `/consumers` read/write endpoints were removed after every retained -consumer had a verified Circuit-First migration mapping. Database upgrade tooling -reads retained legacy rows directly; application features must use the Circuit-First -endpoints above. ## Linked Project Device Review @@ -344,3 +390,38 @@ inverse command. The former direct restore/reconnect endpoints are removed. `displayName` is included in the comparison but is not selected by default in the UI. Updating a project device never triggers synchronization implicitly. + +## Revit CSV Foundation + +- `GET /projects/:projectId/external-csv/configuration` + - returns the complete versioned project configuration or `null` +- `PUT /projects/:projectId/external-csv/configuration` + - request: `expectedRevision` and the complete validated configuration + - executes `external-csv-configuration.update` + - returns configuration, revision and project history state +- `POST /projects/:projectId/external-csv/preview` + - request: file name and Base64-encoded CSV content up to 18 MB + - requires a stored project configuration + - returns transport metadata, SHA-256, row classifications, suspect row + numbers and mapped source values for every recognized IFC object + - does not persist a draft, mutate project state or create a revision +- `POST /projects/:projectId/external-csv/initial-import/plan` + - reparses the file and returns grouped room/family decisions plus current + room, distribution-board and ProjectDevice catalogs + - is stateless and rejects an already imported external source +- `POST /projects/:projectId/external-csv/initial-import/apply` + - requires the expected revision, configuration version, file hash and one + decision for every room and exact family/type group + - reparses the file, blocks unknown family/type values and executes + `external-import.apply-initial` as one undoable project revision + - creates no Circuit or CircuitDeviceRow +- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects` + - resolves the circuit list's distribution board server-side + - returns only external objects assigned to that board, with unassigned + objects first, their source room/marker values and current row-link status + - returns an empty list when the optional Revit import does not exist + - is read-only and exposes no stored command payloads or import bytes + +Parser errors expose stable codes such as `header-not-found`, +`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed +import uploads and parses the file again and verifies its hash. diff --git a/docs/circuit-list-editor-architecture.md b/docs/circuit-list-editor-architecture.md index a81c73a..a8473c9 100644 --- a/docs/circuit-list-editor-architecture.md +++ b/docs/circuit-list-editor-architecture.md @@ -17,13 +17,15 @@ The pure grid modules have no React state and are covered by focused unit tests. ## Domain Model Overview - `CircuitSection` - - Groups circuits by planning section (for example lighting, single-phase, three-phase). - - Owns section metadata (`key`, `displayName`, `prefix`, ordering). + - Represents a protected circuit group in one of the categories lighting, + single-phase or three-phase. + - Owns category, positive group number, generated prefix, display name and + ordering. - `Circuit` - Core electrical unit in the list. - Owns circuit-level identifiers and technical data: - `equipmentIdentifier` (BMK) - - protection data + - one explicit one-to-one protection device - cable data - reserve state - voltage and optional control requirement for future sizing @@ -43,11 +45,10 @@ The pure grid modules have no React state and are covered by focused unit tests. - `ProjectDevice` - Reusable device template entity at project level. - Can be linked to `CircuitDeviceRow` entries, with copied display values on insert. -- Retained legacy migration source - - Old `consumers` rows, mappings and reports are available only to explicit - database upgrade tooling. - - They are not application-domain entities and have no UI or API. - +- `DistributionBoardComponent` + - Represents fixed header components, optional group protection and manually + named auxiliary footer devices with their own unique BMK. + - Group protection may own a separate one-to-one protection configuration. ## Why A Circuit Is Not One Row A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks: @@ -77,7 +78,9 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating `equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`). - Device rows do not have their own BMK. -- Existing identifiers must stay stable unless explicitly changed by user action. +- Existing identifiers stay stable on sort, insert and delete. Explicit + renumbering may change them, and a user-initiated complete-circuit move to + another same-category group assigns the next target-group identifier. - Renumbering is explicit, not implicit on move/sort/delete. ## Data Ownership Split: Circuit vs Device Row @@ -108,14 +111,14 @@ Primary circuit-first editor route: - `/projects/:projectId/circuit-lists/:circuitListId/tree-edit` -The sibling `/tree` route is a read-only structure preview. Old -`/projects/:projectId/circuit-lists` bookmarks redirect to the project page. +Old `/projects/:projectId/circuit-lists` bookmarks redirect to the project +page. A future print view will provide a dedicated read-only presentation. ## Future Persistence Direction -Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md). - -Critical multi-write commands already use explicit persistence transaction -adapters. The next step is to compose these command boundaries into server-side, -project-scoped revisions and change sets. Database backups remain separate from -user-visible project snapshots. +Persistent Undo/Redo, project revisions and logical snapshots are implemented +through project-scoped commands and the shared transaction boundary. Database +backups remain separate from user-visible project snapshots. External-model +exchange and PostgreSQL readiness are specified in +[Project History and External Model Architecture](./project-history-and-external-model-architecture.md) +and remain future work. diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 8fc7ded..c8c02aa 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -28,6 +28,7 @@ The editor can have a selected cell without an active editor input. `editingCell The UI works from a normalized `visibleRows` model built from filtered/sorted sections: - section header rows +- fixed header, group-protection and auxiliary footer component rows - circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`) - device rows (`deviceRow`) - section placeholder rows (`placeholder`) @@ -82,13 +83,21 @@ Intent is separated by drag source type: - device row drag: - drop to existing circuit -> move row(s) into that circuit - drop to placeholder -> create new target circuit and move row(s) + - drop on the upper or lower edge of a circuit row -> create a new target + circuit directly before or after that circuit and move the row(s) + - the center of a circuit row remains the explicit target for adding the + row(s) to that existing circuit - crossing a section boundary requires explicit confirmation - phase type, category, linked project device and local row values remain unchanged - target creation, row assignments and reserve-state updates use one SQLite transaction - circuit drag (BMK handle): - - reorder circuits inside same section only - - cross-section reorder is rejected - - the complete section order is validated and stored in one SQLite transaction + - reorder one or multiple circuits inside the same group without changing BMKs + - move exactly one circuit into another group of the same category + - a cross-group move assigns the highest existing target suffix plus one; + gaps are not filled + - cross-category and cross-group multi-circuit moves are rejected + - same-group reorder validates the complete group order; cross-group move + stores the one exact source/target transition - bulk device row move: - supported via multi-selection + drag - multi-circuit move: @@ -96,7 +105,25 @@ Intent is separated by drag source type: The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device. -Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits. +Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move. + +## Protected Group and Component Controls + +- New groups are created manually in one of the three supported categories and + receive the highest existing category group number plus one. +- Group headings always show the stored editable display name; category labels + do not overwrite names of the default first groups. +- Group reorder buttons move only relative to category peers and never change a + group number or BMK. +- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action. + It follows the persisted group order and updates group prefixes, optional + `.0` protection BMKs and circuit BMKs together. +- Optional group upstream protection and group FI as well as auxiliary footer + components use labeled, type-dependent modals. +- Each circuit protection device uses its own modal; only fields valid for the + selected type are shown. +- Removing a populated group warns with circuit, device-row and group + protection counts. The complete deletion remains persistently undoable. ## Filtering and Sorting @@ -118,7 +145,8 @@ Cross-section device moves show confirmation-required feedback before drop. The - Column visibility and order are configurable. - BMK column (`equipmentIdentifier`) is locked as first column. -- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`). +- Layout is saved in local storage under a project-specific key so switching + distribution boards preserves the same project layout. ## Undo/Redo @@ -138,8 +166,10 @@ Covered operations include: - insert/delete circuit - insert/delete row - edit cell values -- moves (single/bulk rows, circuit reorder) -- renumber and identifier update flows +- moves (single/bulk rows, circuit reorder and same-category group move) +- circuit and group renumber/identifier flows +- component, group and protection editing +- warned populated-group deletion and exact restoration - apply sorted order The editor toolbar exposes availability through its Undo/Redo buttons. The diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 4913da5..4f7f660 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,12 +9,21 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. -- No Revit/CSV/IFCGUID import and export workflow is implemented yet. +- The Revit/CSV foundation and confirmed initial import are implemented. + Existing-row assignment, move and detach plus creation of an external-only + row in an existing or newly created circuit have atomic persistent commands. + Their API planning and editor UI, follow-up import, conflict review and return + export are pending. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. - Final electrical sizing logic is not implemented yet. - No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. - Sorting is view-only until users explicitly apply sorted order. -- Cross-section circuit drag-reorder is intentionally blocked. -- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability. +- Complete-circuit drag supports one circuit at a time between groups of the + same category. Cross-category moves and multi-circuit moves across group + boundaries are intentionally rejected; multi-circuit reorder inside one + group remains supported. +- Protection and cable values are planner-owned manual selections. The later + sizing/warning engine does not yet validate them against load, length or + voltage-drop rules. diff --git a/docs/circuit-list-editor-migration.md b/docs/circuit-list-editor-migration.md deleted file mode 100644 index 1f6364c..0000000 --- a/docs/circuit-list-editor-migration.md +++ /dev/null @@ -1,90 +0,0 @@ -# Circuit List Editor Migration - -## Status - -Upgrade-only. New installations already use the Circuit-First schema and do not -run this workflow. - -## Goal - -Migrate legacy row-first consumers into the circuit-first model without deleting legacy data. - -## Legacy Mapping - -Legacy `Consumer` rows map into: - -- `Circuit` for shared circuit identity and circuit-level technical fields -- `CircuitDeviceRow` for per-device load rows - -Multiple legacy consumers can map into one circuit when they share normalized circuit identity. - -For one circuit list, all new circuits, device rows, trace mappings and the migration report are committed in one SQLite transaction. A failed run therefore leaves none of those prepared migration writes behind. - -## Grouping Strategy (`circuitNumber`) - -Migration groups legacy rows by normalized `circuitNumber`: - -- valid/normalizable values become one target circuit per normalized value -- duplicates are grouped under that circuit (multiple `CircuitDeviceRow`s) -- missing/invalid values trigger generated identifiers and may fall back to `unassigned` section - -## Default Section Backfill - -Before migration, default sections are created/backfilled per circuit list. -This guarantees a valid target section space, including `unassigned` when no section can be inferred. - -## Migration Commands - -Run in this order for local database workflows: - -1. Backup: - - `npm run db:backup` -2. Migrate schema: - - `npm run db:migrate` -3. Verify circuit schema: - - `npm run db:verify:circuit-schema` -4. Backfill missing sections: - - `npm run db:backfill:sections` -5. Migrate legacy consumers: - - `npm run db:migrate:legacy-consumers` - -The migration command finishes with a cutover verification across the complete -database. It exits with an error while any legacy consumer lacks a migration -mapping, including consumers that cannot be migrated because they have no circuit -list assignment. The legacy multi-list UI was removed only after this check passed; -old `/projects/:projectId/circuit-lists` bookmarks redirect to the project page. - -## Validation Checks - -After migration, verify: - -- tree endpoint returns sections/circuits/rows -- grouped duplicate circuit numbers are reported -- generated identifiers are reported where expected -- migrated rows include `legacyConsumerId` traceability -- no duplicate BMKs exist inside one circuit list - -## If Tree Endpoint Returns Empty Sections - -Likely causes: - -- circuit-first tables missing (migration not run) -- sections not backfilled yet -- migrated dataset genuinely empty for selected list - -Actions: - -1. Run schema migration and verification commands. -2. Run section backfill command. -3. Run legacy-consumer migration command. -4. Retry tree endpoint. - -## Legacy Data Retention - -Do not delete legacy consumers yet. - -- legacy read/write endpoints have been removed from the application server -- migration trace tables reference old/new mapping -- retained rows allow upgrade verification and audit of the completed mapping -- a future automatic cleanup migration must migrate and verify old databases - before dropping these source and trace tables diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 0534219..92efc98 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -9,10 +9,8 @@ Der unterstützte Editor ist Circuit-First: Ein Stromkreis ist nicht dasselbe wie eine Gerätezeile. BMK, Schutz- und Kabeldaten gehören zum Stromkreis; Last-, Raum- und Kategoriedaten gehören zur Gerätezeile. -Die frühere Consumer-Oberfläche und ihre API sind entfernt. Die Tabelle -`consumers` sowie Mappings und Reports bleiben ausschließlich erhalten, damit -ältere Datenbanken über den expliziten Upgrade-Befehl migriert und geprüft werden -können. +Die frühere Consumer-Oberfläche, ihre API, Tabellen und Upgrade-Werkzeuge sind +entfernt. Neue Funktionen bauen ausschließlich auf dem Circuit-First-Modell auf. ## Laufzeit @@ -130,8 +128,8 @@ Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots` erzeugt bei passender erwarteter Revision transaktional einen vollständigen, schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und -Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte, -Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots. +Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte sind nicht +Teil des Projekt-Snapshots. Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel. `kind` unterscheidet benannte und automatische Stände. Die zentrale Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen @@ -144,9 +142,7 @@ Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen -Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible -Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie -nicht Bestandteil des logischen Snapshots sind. +Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Die Projektseite bindet diese APIs in einem einklappbaren Bereich „Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte benennen, jeden aufgeführten benannten oder automatischen Stand nach @@ -236,8 +232,10 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. `project.update-settings` versioniert Projektname, interne und externe -Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im -Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der +Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches +Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten +Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist +der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem @@ -246,8 +244,7 @@ Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. -Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und -ausführbar; fehlende Metadaten werden dabei als `null` behandelt. +Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen. Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, @@ -263,16 +260,16 @@ läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt -die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- -Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen -innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die +die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen +innerhalb des unterstützten Laufzeitmodells bleiben +erhalten. Die Projektübersicht verwendet dafür den separaten Collection-Endpunkt `POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den projektgebundenen Endpunkt beschränkt. `distribution-board.insert` versioniert die Anlage einer Verteilung mit -Command-Schema 3 als vollständigen Block aus Verteilung, Stromkreisliste, den +Command-Schema 1 als vollständigen Block aus Verteilung, Stromkreisliste, den drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0` und Überspannungsableiter `-FA`. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. @@ -283,8 +280,16 @@ Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die Freigabe der Netzart in den Projekteinstellungen. -Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier -Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar. +Bereits befüllte Verteilungen werden über +`distribution-board.insert-subtree` und `distribution-board.delete-subtree` +als vollständiger Unterbaum kopiert beziehungsweise gelöscht. Der Snapshot +umfasst Verteilung, Stromkreisliste, Gruppen, Stromkreise, Gerätezeilen, +Verteilerkomponenten und alle zugehörigen Schutzgeräte. Beim Kopieren werden +sämtliche besitzenden UUIDs neu vergeben, während fachliche Verknüpfungen zu +Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare +Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die +Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an; +Löschen verlangt dort eine ausdrückliche Bestätigung. `distribution-board-component.insert` und `distribution-board-component.delete` sowie `distribution-board-component.update` versionieren Anlage, Entfernung, @@ -296,15 +301,15 @@ einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen -allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den -nächsten Arbeitspaketen. +allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten +werden im Editor über eigene Modale und diese Commands verwaltet. Leere Stromkreisgruppen werden über `circuit-group.insert`, `circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen. Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne -Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein -eigener späterer Command. +Gruppenkomponenten. Befüllte Gruppen verwenden getrennt davon den vollständigen +`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung. `circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten- Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern, @@ -346,47 +351,98 @@ Teilwiederherstellungen. Der Circuit-Tree liefert zusätzlich `headerComponents`, gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten -enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen -flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv -erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide +enthalten ihre getrennte Schutzgerätekonfiguration. Die +1:1-Schutzgerätetabellen sind die einzige unterstützte Quelle dieser Daten. +Separate aktive Read-Repositories lesen Komponenten- und beide Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands. `src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß. Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten, vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert. +`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den +bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-, +Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite +und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und +Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten. +Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte +werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung +entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen +`distribution-board-component.*`-Commands; die verbindliche +geräteabhängige Validierung und atomare Revision bleiben serverseitig. +Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene +Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix. +Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere +Gruppen können über `circuit-group.delete` entfernt werden; für befüllte +Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog +gesperrt. +`circuit-protection.update` bildet die eigene persistente Schreibgrenze für +die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen +erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die +Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang +atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen; +Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt +wiederherstellen. +Der vollständige `CircuitSnapshot` enthält den zugehörigen +`protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und +durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten +Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie +Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig. +Im Editor sind die Schutzspalten deshalb eine +schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein +geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`. +Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten +Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die +vollständige erwartete und gewünschte Sortierreihenfolge über +`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert. +Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen +vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie +nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert +Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam; +die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten. +Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen +Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare +`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die +Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins; +Lücken werden nicht automatisch gefüllt. +Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der +enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der +Client sendet dafür den vollständigen aktuellen Unterbaum an +`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben +Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte, +Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig +wieder her. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert; -Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft. -Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie -gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung -hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als -Projektauswahl hochgestuft. -Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer, -Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, +Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder +Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und +enthält bereits den vollständigen externen Modellzustand +aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die +projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, +Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten +1:1-Schutzgerätedaten. Capture, benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide -JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter -bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch -Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer -und bestehende flache Schutzangaben unverändert erhalten. -`project-floor.insert` und `project-room.insert` versionieren die Anlage von -Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden -jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur +JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte +Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit +leerer externer Konfiguration hochgestuft. Die unterstützte +Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor +dieser Baseline werden nicht eingelesen. +Persistente Insert-, Update- und Delete-Commands versionieren Anlage, +Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und +exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein -Raum wird nur entfernt, -solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf -ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den -aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg -mehr. +Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle +Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten +Historienstand. ## Projektgeräte -`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder: -`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie -optionale technische und kategorisierende Felder. +Die Projektgerätekategorie `lighting`, `single_phase` oder `three_phase` ist die +fachliche Klassifikation und Zielgruppe. `phaseType` ist keine separate +Benutzereingabe, sondern wird daraus für Spannung, Verknüpfung und spätere +Dimensionierung abgeleitet. Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät @@ -400,44 +456,188 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. - SQLite ist die aktuell unterstützte Datenbank. - Fremdschlüssel werden für jeden Datenbankkontext aktiviert. -- `npm run db:migrate` wendet Drizzle-Migrationen an. +- `npm run db:migrate` richtet mit Baseline-Migration `0000` und den folgenden + additiven Migrationen das aktuelle Schema ein. - `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten. - `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup. -- `npm run typecheck:scripts` prüft alle TypeScript-Wartungs- und - Upgrade-Skripte mit ihren Anwendungspfaden, ohne Code zu erzeugen. -- Angewendete Migrationen werden niemals nachträglich verändert. -- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad. - Der fachliche Migrationsdienst kennt nur schmale Reader-/Store-Ports unter - `src/domain/ports`; konkrete SQLite-Repositories werden ausschließlich im - CLI-Skript zusammengesetzt. +- `npm run typecheck:scripts` prüft die TypeScript-Wartungsskripte, ohne Code zu + erzeugen. +- Die Baseline ersetzt alle vorigen Entwicklungsmigrationen. Datenbanken, + Snapshots und persistierte Commands aus Vor-Baseline-Ständen werden bewusst + nicht unterstützt. Ab der ersten veröffentlichten Version sind angewendete + Migrationen unveränderlich und Änderungen erfolgen additiv. - Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard- Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit. - Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories; - Upgrade-Schreibvorgänge bleiben in expliziten Migrationsadaptern. + Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories. - Die vollständige Verteilungs-Testfixture liegt unter `tests/support/distribution-board-fixture.ts` und ist kein exportierter Produktions-Schreibweg. -- Migration `0024` bildet die additive relationale Grundlage für geschützte - Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden - Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate - 1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die - bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert. - Ein triggergeführtes Register erzwingt bereits eine normalisierte, +- Die Baseline bildet die relationale Grundlage für geschützte Stromkreisgruppen + 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 eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und - Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden - Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für - veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen - sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume - und UI folgen in abgegrenzten Arbeitspaketen. + 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 bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. +## Revit-CSV-Grundlage + +Der reine Adapter unter `src/external-model/csv` validiert die projektweite +Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine +klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt- +und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte +einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl +`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei. + +`external_csv_configurations` enthält höchstens eine versionierte Konfiguration +je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder +Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5, +Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren +werden Konfigurations-UUID und Projektlink remapped. + +Migration `0003` ergänzt die relationale Grundlage für genau eine implizite +`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte +Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte. +Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte +Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite +Konfigurationsversion am Batch, getrennt von der Schema-Version des +Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten +der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice +und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den +vollständigen Zustand deterministisch und unterscheidet unbekannte von noch +nicht importierten Projekten. Der letzte bestätigte Batch wird aus der +Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. +Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit +dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes, +Matrix und alle internen Links werden weiterhin vollständig erfasst, +stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim +Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben +unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für +einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen +Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes +erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und +validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt. +Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten +Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine +CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, +Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung +erzeugen weiterhin keine dieser Zeilen. + +Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom +weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit +`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe +Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der +effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide +Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln. +`external-object.update-row-assignment` ist diese atomare Schreibgrenze für +bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt +sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden +die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit, +Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft. +Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung; +unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht +still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem +unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die +Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach +Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen. +`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue +Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row +ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und +Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte +und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind. +Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine +normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel +bestehen. +`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit +vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer +externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks. +Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert +gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein +allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei +unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt +dieselben IDs, dasselbe BMK und denselben Schutzsnapshot. +Die drei Zuordnungsadapter teilen sich +`external-object-assignment.persistence.ts` für Snapshotvergleich, +Kompatibilitätsregeln und link-sichere Updates. +`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` +liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer. +Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur +dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte +Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige +Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen +Revit-Import ist die Projektion leer. +`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder +ändern die Konfiguration; der PUT plant Identität und nächsten +Konfigurationsstand serverseitig und verwendet den typisierten Command. +`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und +Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash, +Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte +Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. +`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet +denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen +noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei. +Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen +Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte, +liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die +vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge. +Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben +sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt. +`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei, +erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen. +Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen +Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional +einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende +Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt +stabile interne UUIDs und übergibt den vollständigen Zustand an +`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte +und alle `circuitDeviceRowId` bleiben `null`. +Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt, +Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. +Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den +zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur +die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der +Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende +Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er +zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume, +Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die +Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog +wird der vollständige Import als eine persistente, rückgängig machbare +Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen. +Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über +die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese +Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt. +Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt +den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert +ihn für die betroffene Gruppe. + ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- Revit-/CSV-/IFCGUID-Round-trip +- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und + strukturtreuer Revit-Rückexport - vollständige elektrische Dimensionierung - Produktionsdeployment 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/docs/development-workflow.md b/docs/development-workflow.md index 175891c..b19b7ff 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -45,10 +45,16 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen Testbestand: 1. In der UI ein Projekt `Demo` anlegen. -2. Im Projekt eine Verteilung `UV-01` erstellen. +2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält + danach Hauptschalter, Überspannungsableiter und je eine Gruppe für + Beleuchtung, 1-phasige und 3-phasige Stromkreise. 3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen. -4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich +4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe ziehen. +5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein + zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung, + explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne + reale Projekt- oder Kundendaten prüfen. Dateien unter `data/` und `data/backups/` dürfen nicht committed werden. @@ -90,18 +96,19 @@ erneut direkte Schreibmethoden erhalten. ## Migrationen -1. Bestehende lokale Datenbank sichern: `npm run db:backup`. -2. Schema unter `src/db/schema/` ändern. -3. `npm run db:generate` ausführen. -4. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung +1. Schema unter `src/db/schema/` ändern. +2. `npm run db:generate` ausführen. +3. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung enthalten. -5. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert +4. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert werden. -6. `npm test`, `npm run db:migrate` und +5. `npm test`, `npm run db:migrate` und `npm run db:verify:circuit-schema` ausführen. -Bereits angewendete SQL-Migrationen dürfen nicht geändert oder neu sortiert -werden. Korrekturen erfolgen immer über eine neue Migration. +`0000` ist die saubere Baseline für leere Neuinstallationen. Vor-Baseline- +Entwicklungsdatenbanken werden nicht aktualisiert. Nach Veröffentlichung der +ersten Version dürfen angewendete SQL-Migrationen nicht geändert oder neu +sortiert werden; Korrekturen erfolgen dann immer über eine neue Migration. ## Pull-Request-Handoff diff --git a/docs/local-db-circuit-first-migration.md b/docs/local-db-circuit-first-migration.md deleted file mode 100644 index 995f6bf..0000000 --- a/docs/local-db-circuit-first-migration.md +++ /dev/null @@ -1,137 +0,0 @@ -# Local DB Migration: Circuit-First Schema - -This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in `src/db/migrations`. - -This document is an upgrade runbook for databases created before the Circuit-First -cutover. A clean installation only needs the normal `db:migrate` and schema -verification steps documented in the main README. - -## Safe command order - -1. Backup local DB (required before schema/data migration) - -```bash -npm run db:backup -``` - -The command uses SQLite's online backup API, so committed WAL changes are included -even while the application is running. It opens the resulting standalone database -and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass. -Database backups are operational recovery files and remain separate from the future -user-visible project version history. - -2. Apply pending schema migrations (includes `0008_circuit_first_model`, the additive - `0009_project_device_circuit_fields` transition and the post-cutover - `0011_project_device_canonical_fields` cleanup) - -```bash -npm run db:migrate -``` - -3. Verify circuit-first tables exist - -```bash -npm run db:verify:circuit-schema -``` - -4. Backfill default sections for existing `circuit_lists` - -```bash -npm run db:backfill:sections -``` - -5. Run legacy consumer -> circuit/device-row migration explicitly - -```bash -npm run db:migrate:legacy-consumers -``` - -## Verification SQL - -Run these against `data/leistungsbilanz.db`: - -```sql -SELECT name -FROM sqlite_master -WHERE type = 'table' - AND name IN ( - 'circuit_sections', - 'circuits', - 'circuit_device_rows', - 'legacy_consumer_circuit_migrations', - 'legacy_consumer_migration_reports' - ) -ORDER BY name; -``` - -The verification command also checks the circuit-first project-device columns added by migration `0009`. - -```sql -SELECT circuit_list_id, key, prefix, sort_order -FROM circuit_sections -ORDER BY circuit_list_id, sort_order; -``` - -```sql -SELECT circuit_list_id, COUNT(*) AS circuits -FROM circuits -GROUP BY circuit_list_id; -``` - -```sql -SELECT c.circuit_list_id, COUNT(r.id) AS device_rows -FROM circuits c -LEFT JOIN circuit_device_rows r ON r.circuit_id = c.id -GROUP BY c.circuit_list_id; -``` - -## API verification - -After the steps above: - -`GET /api/projects/:projectId/circuit-lists/:circuitListId/tree` - -Expected response shape: - -```json -{ - "circuitListId": "string", - "sections": [ - { - "id": "string", - "key": "lighting|single_phase|three_phase|unassigned|...", - "displayName": "string", - "prefix": "-1F|-2F|-3F|-UF|...", - "sortOrder": 10, - "circuits": [ - { - "id": "string", - "equipmentIdentifier": "-2F1", - "displayName": "string", - "sortOrder": 10, - "isReserve": false, - "circuitTotalPower": 1.23, - "deviceRows": [ - { - "id": "string", - "displayName": "string", - "quantity": 1, - "powerPerUnit": 0.3, - "simultaneityFactor": 1, - "rowTotalPower": 0.3 - } - ] - } - ] - } - ] -} -``` - -If migrations were not applied, endpoint may return an empty fallback with a warning. - -## Visual verification - -Manual device rows and multi-device circuits can be created directly in the -circuit-list editor. The former development scripts for direct database writes -were removed because they bypassed project revisions and persistent undo/redo. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index dffecfc..4c0a8ea 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -84,7 +84,8 @@ Implemented named-snapshot foundation: stores the complete project state in one SQLite transaction - the versioned payload contains project settings, distribution boards, circuit lists, sections, circuits with device rows, project devices, floors - and rooms while excluding global and upgrade-only data + and rooms while excluding the global device catalog and other + non-project-owned data - snapshot creation/listing does not create a project revision or affect undo/redo eligibility - public Create/List endpoints expose metadata but not the logical payload @@ -93,8 +94,6 @@ Implemented named-snapshot foundation: - restore records a new `restore` revision containing both the selected target state and the complete pre-restore inverse; undo/redo therefore remains available after application restarts -- compatible upgrade-only Consumer links, mappings and reports are preserved - separately and are never imported into the logical snapshot payload - arbitrary restore payloads are rejected by the generic user-command endpoint; only server-stored snapshots can initiate a forward restore - the project page provides an initially collapsed snapshot/timeline panel, @@ -263,8 +262,9 @@ Completed foundation: - `project-device.insert` and `project-device.delete` preserve stable device ids; delete undo restores links only from complete unchanged disconnected-row snapshots in the same transaction -- `project.update-settings` persists both project voltage defaults, its exact - inverse, revision and history transition atomically; the project page and +- `project.update-settings` persists the public-building classification and + both project voltage defaults with their exact inverse, revision and history + transition atomically; the project page and existing PUT route require the current expected revision; the same transaction derives every project-device and circuit voltage again, so Undo/Redo cannot leave mixed voltage states diff --git a/docs/revit-requirements-llm-context.md b/docs/revit-requirements-llm-context.md index 965eb47..314820b 100644 --- a/docs/revit-requirements-llm-context.md +++ b/docs/revit-requirements-llm-context.md @@ -52,7 +52,7 @@ Wichtige fachliche Grenzen: Für einen späteren Revit-Austausch besonders relevante Daten sind: - Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung, - Projektspannungen. + Kennzeichnung als öffentliches Gebäude und Projektspannungen. - Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und Raumname. - Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor. diff --git a/docs/spec/03-data-model-concept.md b/docs/spec/03-data-model-concept.md index 545c450..dec30f6 100644 --- a/docs/spec/03-data-model-concept.md +++ b/docs/spec/03-data-model-concept.md @@ -6,9 +6,10 @@ This is a conceptual model for Codex. It is not a final database schema, but the A distribution board contains a circuit list. -A circuit list contains sections. +A circuit list contains circuit groups. `CircuitSection` is the persisted name +of that group aggregate. -A section contains circuits. +A circuit group contains circuits of one category. A circuit contains zero, one or multiple device rows. @@ -60,29 +61,28 @@ User-facing fields: - `section` - `displayName` optional - `circuitTotalPower` -- `protectionType` -- `protectionRatedCurrent` -- `protectionCharacteristic` +- `protectionDevice` - `cableType` - `cableCrossSection` - `cableLength` -- `rcdAssignment` optional - `terminalDesignation` optional -- `voltage` optional +- `voltage`, derived from project settings and phase category - `controlRequirement` optional, for example DALI or KNX - `status` optional - `isReserve` optional - `remark` optional +`protectionDevice` is the circuit-owned one-to-one protection configuration. +It contains the device type, rated current and only the type-dependent values +such as trip characteristic or residual current. Newly created circuits +receive the category default defined in the protection catalog. + Circuit-level fields only: -- protection type -- protection rated current -- protection characteristic +- protection device and all of its technical values - cable type - cable cross-section - cable length -- RCD assignment - terminal designation - circuit total power diff --git a/docs/spec/06-future-sizing-and-calculations.md b/docs/spec/06-future-sizing-and-calculations.md index b9a5c24..f2bb232 100644 --- a/docs/spec/06-future-sizing-and-calculations.md +++ b/docs/spec/06-future-sizing-and-calculations.md @@ -14,12 +14,18 @@ Future suggestions or checks may include: - cable type - number of cores - cable cross-section +- halogen-free cable or line requirement for public buildings - minimum cable cross-section based on current - maximum permissible cable length based on voltage drop - warning messages if cable length, cross-section or protection device do not match Users must remain able to manually override suggestions. +The project-owned `isPublicBuilding` setting is the authoritative input for +the later halogen-free selection rule. The setting is persisted now; this +document does not yet define or implement the complete cable catalog or sizing +algorithm. + ## Initial Design Rule Examples These are common planning defaults, not a replacement for full norm-compliant calculation. diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 03b6a4c..b665e85 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -342,9 +342,8 @@ Implemented foundation: - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks - project-device row synchronization has real multi-row SQLite commit and rollback coverage - BMK swaps and section reorder have real SQLite commit and rollback coverage -- the legacy consumer migration service depends only on narrow domain ports; - all concrete repositories are composed at the script entry point -- legacy circuit, row, mapping and report writes have real late-failure rollback coverage +- the former Consumer migration service, mappings and reports were removed with + the pre-release database cutover - circuit and device-row persistence value mapping is separated from the general repositories - obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and Project repository writes are removed; integration fixtures live under @@ -362,8 +361,9 @@ Implemented foundation: direct global SQLite access - TypeScript maintenance and upgrade scripts have a dedicated no-emit typecheck, including all referenced application modules -- the legacy consumer UI and application read/write endpoints are removed after verified data cutover -- retained legacy rows are accessible only through explicit database upgrade tooling +- the former Consumer UI, API, tables and upgrade tooling are removed +- migration `0000` is the clean baseline for new installations; pre-baseline + development databases are intentionally unsupported - project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields ## Phase 12: Project Revisions and Persistent Undo / Redo @@ -465,9 +465,9 @@ Implemented foundation: - ProjectDevice synchronization and disconnect endpoints also require `expectedRevision`; their UI undo uses project-wide persistent history and the obsolete direct restore/reconnect write paths are removed -- project voltage settings use `project.update-settings`; both values, inverse, - revision and history transition commit atomically and the former direct - settings write is removed +- project settings use `project.update-settings`; metadata, public-building + classification, voltage values, inverse, revision and history transition + commit atomically and the former direct settings write is removed - distribution-board setup uses `distribution-board.insert` with a complete stable board/list/default-section snapshot; Undo removes only an unchanged empty setup and the former direct controller write is removed @@ -532,6 +532,12 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip +Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist +implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe +[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) +und die verbindlichen +[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md). + Goal: Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format. @@ -559,11 +565,11 @@ PostgreSQL should be introduced when shared multi-user operation, background job Goal: -After the legacy consumer path has been removed, provide one accurate entry point for developers, contributors and LLM-assisted work. +With the former Consumer path removed, provide one accurate entry point for developers, contributors and LLM-assisted work. Timing: -- perform the final rewrite after legacy cutover so the documentation describes only the supported architecture +- keep the post-cleanup documentation limited to the supported architecture - update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design README target structure: @@ -618,7 +624,8 @@ Implemented handoff: - README is a concise setup and project entry point without prototype status lists - `docs/README.md` separates current, operational, future, specification and archived material -- current runtime, module boundaries and upgrade-only legacy data are documented explicitly +- current runtime and module boundaries are documented without compatibility + paths for unsupported pre-release data formats - contributor workflow covers clean databases, safe demo data, migrations, tests and commits - development Compose is clearly separated from the currently unsupported production deployment - `AGENTS.md` points LLM-assisted work to supported code paths and deferred architecture diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 4614858..c90dd24 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -10,15 +10,15 @@ requirements and intended sequencing, not proof of implementation. ## Distribution Board Components and Protection Groups -- [ ] Implement the agreed protected circuit-group and distribution-board +- [x] Implement the agreed protected circuit-group and distribution-board component model in phased work packages. - [x] Phase A: central protection catalog, validation, defaults and pure grouped BMK rules. -- [x] Phase B1: additive relational schema, group backfill, component/protection - tables and shared BMK uniqueness. -- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility - mapping. -- [x] Phase C1: versioned new-board command with three groups and fixed header +- [x] Phase B1: relational group, component and protection tables with shared + BMK uniqueness. +- [x] Phase B2: complete snapshot/transfer schema for the supported project + state. +- [x] Phase C1: new-board command with three groups and fixed header components. - [x] Phase C2a1: persistent insert/delete commands for mutable group protection and auxiliary components. @@ -33,9 +33,18 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase D3b: persistent populated-group delete/restore command. - [x] Phase E1: tree read model for components, groups and protection devices. - [x] Phase E2a: pure header/group/footer structure projection. -- [ ] Phase E2b: render the structure projection in the editor grid. -- [ ] Phase E: editor projection and editing. -- [ ] Phase F: documentation and full GUI verification. +- [x] Phase E2b: render the structure projection in the editor grid. +- [x] Phase E3a: create, edit and delete mutable group and footer components. +- [x] Phase E3b1: create, rename and safely delete empty circuit groups. +- [x] Phase E3b2a: persistent circuit-protection update command. +- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal. +- [x] Phase E4a: persistent same-category group reorder controls. +- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning. + - [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically. + - [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment. + - [x] Populated-group delete warning and explicit subtree deletion. +- [x] Phase E: editor projection and editing. +- [x] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until the planner supplies the calculation requirements. - Detailed specification: @@ -43,22 +52,22 @@ requirements and intended sequencing, not proof of implementation. ## First Release Baseline -Complete this immediately before the first supported release, once the schema -and initial feature set are frozen: +The pre-release cleanup established the following compatibility boundary: -- Decide explicitly whether pre-release databases, portable JSON exports, - logical snapshots and persisted command histories remain supported. -- Create one clean baseline migration that builds the complete release schema - on an empty database. -- Replace the active development migration chain and Drizzle metadata only if - no released installation depends on it. -- Remove upgrade-only database, snapshot and command compatibility code only - for formats that are explicitly declared unsupported. -- Preserve the pre-release migration history in Git history or a release tag. -- Verify installation, migrations, application startup and core workflows - against a completely empty database. -- Mark the resulting compatibility boundary with the first release tag. -- Treat every migration published after that release as immutable. +- [x] Pre-baseline databases, portable JSON exports, logical snapshots and + persisted command histories are explicitly unsupported. +- [x] Migration `0000` builds the complete supported schema on an empty + database. +- [x] The development migration chain and Drizzle metadata were replaced + before any supported release depended on them. +- [x] Upgrade-only database, snapshot and command compatibility paths were + removed. +- [x] Pre-cleanup development history remains available through Git history. +- [x] Clean installation, migration, startup and core workflows were verified + against an empty database. +- [ ] Mark this compatibility boundary with the first supported release tag. +- After that release, every published migration is immutable and later schema + changes must use new additive migrations. ## Project History @@ -100,9 +109,16 @@ and initial feature set are frozen: device rows with a valid phase are three-phase. - [x] Changing project voltage settings updates all project devices and circuits atomically in the same persistent Undo/Redo step. -- [x] Database migration `0019` and snapshot schema version `5` normalize older - stored values. Version-four and older imports remain supported and are - normalized while being upgraded. +- [x] Baseline migration `0000` stores only canonical derived values. + Pre-baseline imports are intentionally unsupported. + +## Building Classification + +- [x] Store whether a project represents a public building. +- [x] Edit the value in the project settings and preserve it through persistent + Undo/Redo, snapshots and portable project transfers. +- [x] Provide the value as an explicit future sizing input for selecting + halogen-free cables and lines; automatic sizing remains deferred. ## Circuit List Power Summary diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index 5bdbd87..9c7ee11 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -2,10 +2,10 @@ ## Status -This document defines the agreed target behavior for distribution-board -components, protected circuit groups and circuit protection devices. Phase A -and the relational Phase-B1 foundation are implemented; later sections remain -requirements and are not proof of runtime or editor support. +This document records the agreed and implemented behavior for +distribution-board components, protected circuit groups and circuit +protection devices. Delivery phases A through F are complete. The current +runtime paths are documented in `docs/current-architecture.md`. Full rule-based protection and cable sizing is deliberately deferred. This phase establishes the structure and manually selected technical values that a @@ -33,7 +33,7 @@ later calculation and warning engine will evaluate. - channel-level relationships between actors and controlled circuits - PostgreSQL or multi-user operation -## Existing Model Audit +## Implemented Model Boundary ### Keep @@ -43,33 +43,28 @@ later calculation and warning engine will evaluate. - Circuit-level load, cable and control fields remain circuit-owned. - Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo remain authoritative. -- `CircuitSection` remains the migration starting point for a protected circuit +- `CircuitSection` is the persisted representation of a protected circuit group. - Grid projection remains separate from persisted domain structure. ### Change -- A `CircuitSection` gains an explicit category and group number and represents +- A `CircuitSection` has an explicit category and group number and represents one protected circuit group. -- Circuit protection changes from loosely related flat strings to an explicit +- Circuit protection uses an explicit one-to-one protection-device model. - Distribution-board components become independent BMK-bearing list entries. - Equipment-identifier uniqueness covers circuits and all distribution-board components in the complete circuit list. - Moving a circuit to another group of the same category atomically assigns the next identifier of the target group. -- New distribution boards create three default groups rather than treating the - optional unassigned compatibility section as a normal fourth group. +- New distribution boards create exactly three default groups. -### Remove Eventually +### Remaining Model Cleanup -- hard-coded assumptions that each category has exactly one section -- numbering that only understands `prefix + integer` -- free-text protection-device combinations that cannot be validated -- `rcdAssignment` as a substitute for an explicit group relationship - -Removal happens only after migrated state, snapshots, commands and the editor -use the replacement model. +- The older optional `rcdAssignment` circuit field still exists separately + from the explicit group relationship. Removing it requires its own reviewed + domain and UI change; it is not a compatibility path for pre-release data. ## Target Domain Structure @@ -463,7 +458,7 @@ Every command must: - commit the domain mutation, revision and history transition atomically - remain undoable and redoable after application restart -## Snapshot and Transfer Compatibility +## Snapshot and Transfer Boundary The supported logical project snapshot and portable JSON transfer must include: @@ -472,28 +467,15 @@ The supported logical project snapshot and portable JSON transfer must include: - circuit protection-device state - all new component relationships and sort positions -Introducing the model requires a snapshot-schema version increase and explicit -upgraders for supported older payloads. +Current project-state schema version `2` contains the complete supported +structure above and the project building classification. It is shared by +logical snapshots, restore, Undo/Redo and portable JSON transfer. Supported +baseline version `1` is upgraded with the non-public default; payloads from +before that baseline are rejected before any write. -Database migration rules: - -- preserve all existing stable UUIDs -- preserve existing circuit equipment identifiers during schema migration -- map the existing lighting, single-phase and three-phase sections to group 1 - of the respective category -- preserve an existing unassigned section only when required by existing data; - it is not created for a new distribution board -- preserve existing flat circuit protection values when creating the new - one-to-one protection state -- do not silently replace an existing protection selection with a new default -- allow legacy identifiers to remain until an explicit, collision-safe - conversion or renumber action is approved - -New defaults apply to newly created circuits and boards, not as an implicit -rewrite of existing planning data. - -Historical persisted commands and snapshots that remain in the supported -compatibility window must still deserialize and execute after the migration. +Migration `0000` creates the complete relational model on an empty database. +New defaults apply when boards, groups or circuits are created; they never +silently overwrite a planner's later selection. ## Later Sizing and Warning Engine @@ -543,7 +525,7 @@ Acceptance: - invalid rating/type/field combinations are rejected - defaults produce the exact agreed values -### B. Persistence and Compatibility Model +### B. Persistence and Snapshot Model Status: Complete. @@ -551,44 +533,31 @@ Status: Complete. - add distribution-board component persistence - add one-to-one circuit protection persistence - implement shared BMK uniqueness -- generate and inspect one additive migration -- add snapshot/transfer schema upgrade -- preserve existing identifiers and protection values +- create the complete relational baseline schema +- include the model in snapshots and portable transfers +- preserve identifiers and protection values within supported project state -Implemented in B1: +Implemented persistence: -- additive migration `0024` adds group identity to circuit sections and maps - the three established sections to group 1 without changing UUIDs +- baseline migration `0000` creates group identity, components and protection + state directly for clean installations - separate component and circuit/component protection tables preserve the one-to-one ownership boundaries - a trigger-maintained circuit-list registry rejects normalized BMK collisions across circuits and distribution-board components -- the existing flat circuit protection fields remain untouched until their - deterministic snapshot/runtime transition is implemented -- clean and populated pre-migration databases are covered by migration tests -- newly created legacy-compatible board structures receive the same initial - group identity while their persisted command format remains unchanged - -Implemented in B2: - -- snapshot schema version 7 includes group identity, distribution-board +- snapshot schema version `2` includes group identity, distribution-board components and both one-to-one protection collections - capture, named and automatic snapshots, restore, Undo/Redo and portable JSON transfers preserve the complete new relational state - duplicate imports remap component UUIDs and both protection-owner references together with the existing project structure -- versions 1 through 6 remain readable; version 6 maps the three established - section keys to group 1 and leaves new collections empty -- older flat circuit protection values remain present and are not guessed into - strict new protection configurations -- current snapshots reject invalid ownership, duplicate group numbers, +- snapshots reject invalid ownership, duplicate group numbers, cross-entity BMKs and invalid protection configurations before persistence Acceptance: -- a current database migrates without data loss - an empty database creates the complete schema -- old supported snapshots and transfers upgrade deterministically +- unsupported pre-release snapshots and transfers are rejected before writing - BMK collisions across circuits and components are rejected ### C. Persistent Commands and Board Defaults @@ -602,14 +571,13 @@ Status: Complete. Implemented in C1: -- `distribution-board.insert` schema version 3 carries stable UUIDs for three +- `distribution-board.insert` schema version `1` carries stable UUIDs for three initial groups, main switch `-Q0` and surge protective device `-FA` - its inverse verifies and removes the unchanged complete structure; Redo restores the same UUIDs - a late revision/history failure rolls back board, list, groups and components together -- stored schema versions 1 and 2 retain four legacy sections and receive no - invented components +- unsupported command schema versions are rejected Implemented in C2a1: @@ -739,8 +707,7 @@ Acceptance: ### E. Editor Projection and Editing -Status: In progress. The complete tree read model E1 and pure structural -projection E2a are complete. Grid rendering and editing remain pending. +Status: Complete. - render fixed header components - render group components and circuit blocks @@ -749,7 +716,7 @@ projection E2a are complete. Grid rendering and editing remain pending. - add type-dependent protection editing - keep spreadsheet selection, keyboard and drag behavior coherent -Implemented in E1/E2a: +Implemented in E1/E2a/E2b/E3a: - the tree response exposes fixed header, group and auxiliary footer components plus both one-to-one protection layers @@ -759,6 +726,43 @@ Implemented in E1/E2a: circuit blocks and free placeholder together - empty groups remain visible and circuit device rows remain owned by their circuit block +- the editor renders fixed header, group protection and auxiliary footer + components as read-only full-width rows +- structural component rows are excluded from spreadsheet cell navigation, + insertion and deletion commands until their dedicated editing controls exist +- group headings offer at most one optional upstream protection device and one + optional group RCD; the agreed group-RCD default is applied only when it is + created +- mutable group protection and auxiliary footer components use labeled modals, + type-dependent protection fields and persistent project commands +- fixed main-switch and surge-protection header components remain read-only +- users can create a group in any supported category; its number is the + category's highest existing number plus one and its prefix is generated +- group editing changes only the display name; category, number, prefix and + child BMKs remain stable +- an exactly empty group can be removed through the compact persistent group + command +- `circuit-protection.update` inserts or updates one validated 1:1 protection + snapshot, rejects stale state and preserves exact persistent Undo/Redo +- user commands cannot remove circuit protection; a nullable target exists + only as the inverse of adding protection to a retained circuit without it +- newly inserted circuits and placeholder targets carry the category-specific + default protection in their complete atomic snapshot +- circuit deletion and restoration preserve the exact 1:1 protection row + together with the circuit and all device rows +- the editor modal exposes only fields valid for the selected protection type; + the old flat protection columns now project the new 1:1 data read-only +- group controls move a group only to the adjacent peer of the same category; + the complete list order is one persistent command and no number or BMK changes +- explicit category-wide group renumbering follows the visible persisted group + order, updates every affected prefix and child BMK atomically and preserves + outgoing-circuit suffixes +- dragging one circuit into another group of the same category moves the + complete circuit and assigns the highest target suffix plus one; same-group + multi-circuit drag remains a pure reorder +- populated-group deletion shows the affected circuit, device-row and group + protection counts before sending the complete subtree to the guarded + delete/restore command Acceptance: @@ -769,8 +773,12 @@ Acceptance: ### F. Documentation and Full Verification +Status: Complete. Documentation, automated verification and the agreed GUI +checklist are complete. + - update current architecture, API and interaction documentation -- update demo/sample data +- update the safe local demo-data instructions; no versioned customer-like + seed data is introduced - run focused domain and transaction tests - run the complete required test, build and type-check suite - perform the agreed GUI test checklist @@ -778,13 +786,13 @@ Acceptance: Acceptance: - documentation distinguishes implemented behavior from later sizing -- clean install and migrated database both work +- clean installation and baseline migration work - snapshots, JSON transfer and Undo/Redo cover all new state ## Risks and Mitigations -- Mixed legacy and grouped BMKs: - preserve old identifiers and provide an explicit conversion path. +- Incorrect implicit BMK changes: + preserve identifiers during sorting and require explicit renumbering. - Cross-entity BMK collisions: enforce one transaction-level uniqueness boundary for the complete list. - Partial destructive writes: @@ -795,17 +803,19 @@ Acceptance: use one shared typed catalog for UI, API and domain validation. - Silent planning changes: apply defaults only on creation and keep later recommendations explicit. -- Snapshot incompatibility: - version payloads and test every supported upgrade path. +- Future snapshot evolution: + introduce a new explicit schema version and upgrader only after defining its + supported compatibility window. -## Open Implementation Decisions +## Resolved Implementation Decisions -These decisions may be resolved during the relevant delivery phase without -changing the agreed domain behavior: - -- exact relational implementation of the shared BMK ownership boundary -- presentation of group ownership through indentation, headings or a column -- filter behavior for structural component rows -- explicit UI for converting retained legacy identifiers to grouped numbering -- whether fixed header components become editable beyond name and BMK in this - phase +- a trigger-maintained circuit-list registry enforces shared BMK uniqueness + across circuits and distribution-board components +- group headings and full-width structural rows show ownership without adding + a permanent group column +- structural component rows remain outside editable-cell filtering and + spreadsheet navigation; circuit filtering continues to preserve complete + circuit blocks +- explicit group renumbering updates grouped identifiers collision-safely +- fixed header components remain read-only; mutable group and auxiliary + components use their dedicated modals diff --git a/docs/spec/revit-csv-integration-requirements.md b/docs/spec/revit-csv-integration-requirements.md new file mode 100644 index 0000000..973c1a6 --- /dev/null +++ b/docs/spec/revit-csv-integration-requirements.md @@ -0,0 +1,1373 @@ +# Revit-CSV-Integration – Anforderungen und Implementierungsanweisung + +## 1. Status und Verbindlichkeit + +Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**. + +Es unterscheidet ausdrücklich zwischen: + +- **Bestätigte Anforderung:** vom Anwender festgelegtes Verhalten. +- **Bestehende Architekturvorgabe:** bereits geltende Regel des Projekts. +- **Implementierungsvorschlag:** empfohlene technische Aufteilung, noch kein unveränderliches Datenbankschema. +- **Offene Entscheidung:** darf erst in der jeweils genannten Phase konkretisiert werden. + +Codex darf aus diesem Dokument kein vollständiges Event-Sourcing, keine PostgreSQL-Migration, keine automatische Stromkreisplanung und keine direkte Dynamo-Anbindung ableiten. + +Vor Implementierungsbeginn sind insbesondere zu lesen: + +- `AGENTS.md` +- `docs/current-architecture.md` +- `docs/spec/03-data-model-concept.md` +- `docs/spec/05-linked-devices-and-sync.md` +- `docs/project-history-and-external-model-architecture.md` +- `docs/spec/07-implementation-phases-todo.md` +- `docs/circuit-list-editor-known-limitations.md` + +## 2. Ziel + +Die Anwendung soll Revit-Objekte der Kategorie **Elektroinstallation** über eine CSV-Datei einlesen, dauerhaft anhand ihrer `IfcGUID` erkennen und mit der manuellen Elektroplanung in Leistungsbilanz verbinden. + +Der Workflow soll insbesondere vermeiden, dass Stromkreiskennzeichnungen und Leistungen für jedes Revit-Objekt händisch nachgetragen werden müssen. + +Der Nutzer soll: + +1. Revit-Objekte importieren, +2. Räume aus dem Import anlegen oder vorhandenen Räumen zuordnen, +3. Räume und einzelne Objekte Verteilungen zuordnen, +4. Objekte klassifizieren und optional mit `ProjectDevice`-Vorlagen verknüpfen, +5. noch nicht verschaltete Objekte im Stromkreislisteneditor manuell Stromkreisen und Gerätezeilen zuweisen, +6. Stromkreiskennzeichnung und Leistung in eine strukturgleiche Rückgabe-CSV schreiben, +7. denselben vollständigen Modellstand später erneut importieren und Änderungen kontrolliert abgleichen. + +## 3. Grundlegende Produktgrenzen + +### 3.1 Revit bleibt optional + +**Bestätigte Anforderung** + +Leistungsbilanz muss vollständig ohne Revit-Integration nutzbar bleiben. + +- Projekte, Ebenen, Räume, Verteilungen, Projektgeräte, Stromkreise und Gerätezeilen bleiben manuell anlegbar. +- Ein Projekt darf ausschließlich manuelle Daten, ausschließlich verknüpfte Revit-Daten oder eine Mischung enthalten. +- Revit ist nur für die Existenz und die Quellattribute der tatsächlich verknüpften Modellobjekte führend. +- Revit ist nicht die alleinige Quelle des gesamten Leistungsbilanzprojekts. + +### 3.2 Circuit-First-Modell bleibt unverändert + +**Bestehende Architekturvorgabe** + +```text +Project +├── Floor +│ └── Room +├── ProjectDevice +└── DistributionBoard + └── CircuitList + └── CircuitSection + └── Circuit + └── CircuitDeviceRow +``` + +- Ein `Circuit` ist nicht dasselbe wie eine `CircuitDeviceRow`. +- BMK, Schutz- und Kabeldaten gehören zum Stromkreis. +- Menge, Leistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe und Kategorie gehören zur Gerätezeile. +- Ein importiertes Revit-Objekt darf vor seiner Stromkreiszuordnung eigenständig existieren. +- Der Import legt nicht automatisch Stromkreise oder Gerätezeilen an. + +### 3.3 Stable IDs und BMK + +**Bestehende Architekturvorgabe** + +- `IfcGUID` ersetzt niemals eine interne UUID. +- `IfcGUID` wird niemals als Stromkreis-BMK verwendet. +- Bestehende interne UUIDs und BMKs dürfen nicht automatisch geändert werden. +- Neunummerierung bleibt eine ausdrückliche Benutzeraktion. +- Import, Sortierung, Verschieben und Folgeabgleich führen nicht automatisch zu einer Neunummerierung. + +## 4. Umfang der ersten Ausbaustufe + +### 4.1 Enthalten + +**Bestätigte Anforderungen** + +- projektweite CSV- und Mappingkonfiguration; +- Import vollständiger CSV-Dateien; +- Import von Revit-Objekten der Kategorie `Elektroinstallation`; +- deterministische Identifikation über `IfcGUID`; +- Import beziehungsweise Verknüpfung von Räumen; +- manuelle Verknüpfung von Räumen mit Ebenen; +- manuelle Anlage der Verteilungen; +- Standardverteilung je Raum und abweichende Verteilung je Objekt; +- exakte Klassifizierung über den CSV-Wert `Familie und Typ`; +- konfigurierbare Behandlung von Einzel- und Mehrfachsteckdosen; +- optionale Verknüpfung importierter Objekte mit vorhandenen `ProjectDevice`-Vorlagen; +- Erstellen neuer `ProjectDevice`-Vorlagen aus ausgewählten Importobjekten; +- lokale Planungswerte und Abweichungen bereits vor der Stromkreiszuordnung; +- projektweites Import-, Staging- und Abgleichs-UI; +- Bereich für unzugeordnete externe Objekte im Stromkreislisteneditor; +- manuelle Zuordnung zu bestehenden Gerätezeilen, bestehenden Stromkreisen oder neuen Stromkreisen; +- Rückexport von vollständiger Stromkreiskennzeichnung und elektrischer Leistung; +- Erhaltung der ursprünglichen CSV-Struktur; +- kontrollierter Folgeimport mit Einzel- und Sammelentscheidungen; +- atomare, revisionierte und rückgängig machbare Übernahmen. + +### 4.2 Ausdrücklich nicht enthalten + +**Bestätigte Anforderungen** + +- automatische Stromkreisbildung in der ersten Ausbaustufe; +- automatische Übernahme späterer Stromkreisvorschläge; +- Import von Verteilungen aus Revit; +- Import einer separaten MEP-Raumliste; +- Teilimporte einzelner Ansichten oder Geschosse; +- Verwaltung mehrerer sichtbarer Modellquellen; +- direkte Dynamo- oder API-Synchronisation; +- PostgreSQL- oder Mehrbenutzerbetrieb; +- automatische Reservekreisbildung; +- automatische elektrische Dimensionierung; +- automatische Konfliktentscheidung; +- automatische Freigabe oder Wiederverwendung leer gewordener Stromkreisnummern; +- Platzhalter, Teiltextsuche oder reguläre Ausdrücke in der ersten Familie-und-Typ-Klassifizierung. + +## 5. Begriffe + +### 5.1 Externes Modellobjekt + +Ein importiertes Revit-Exemplar mit eigener `IfcGUID`. + +Es ist weder automatisch ein `ProjectDevice` noch automatisch eine `CircuitDeviceRow`. + +### 5.2 Quellwert + +Ein Wert, der aus der zuletzt akzeptierten Revit-CSV stammt. + +Beispiele: + +- Revit-Raumnummer; +- Revit-Raumname; +- Familie und Typ; +- CAx-Auswahlkenner; +- vorhandene Stromkreiskennzeichnung; +- vorhandene elektrische Leistung. + +### 5.3 Lokaler Planungswert + +Ein in Leistungsbilanz bestätigter beziehungsweise bearbeiteter Wert. + +Lokale Planungswerte dürfen durch Importe niemals still überschrieben werden. + +### 5.4 Wirksame Menge + +Die fachliche Menge, die für Gerätezeile, Leistungsberechnung und Steckdosenbegrenzung zählt. + +Beispiel: + +```text +2 Doppelsteckdosen + 2 Einzelsteckdosen += 4 Revit-Objekte += 6 physische Steckdosen += wirksame Menge 6 +``` + +## 6. Revit-CSV-Vertrag + +### 6.1 Konfigurierbare Transporteigenschaften + +**Bestätigte Anforderung** + +Die Konfiguration gilt projektweit und muss über eine GUI bearbeitbar sein. + +Mindestens konfigurierbar: + +- Encoding; +- Trennzeichen; +- Dezimaltrennzeichen; +- Zahlenformat; +- Einheit und Umrechnungsregel für Leistung; +- Spaltenname für `IfcGUID`; +- Spaltenname für Raumnummer; +- Spaltenname für Raumname; +- Spaltenname für `Familie und Typ`; +- Spaltenname für `CAx_Auswahlkenner`; +- Spaltenname für Stromkreiskennzeichnung; +- Spaltenname für elektrische Leistung; +- Spaltenname für den Mengenparameter einer Mehrfachsteckdose; +- weitere Quellspalten-zu-internen-Feld-Mappings. + +Erste Standardwerte: + +```text +Encoding: UTF-8 +Trennzeichen: ; +Leistung in Revit: W +``` + +### 6.2 Tatsächlich beobachtetes CSV-Profil + +Die bereitgestellte Referenzdatei besitzt: + +```text +Encoding: UTF-8 mit BOM +Zeilenende: CRLF +Trennzeichen: Semikolon +Quotierung: alle Felder in doppelten Anführungszeichen +Zeile 1: Titel-/Metadatenzeile +Zeile 2: Kopfzeile mit 12 Spalten +Zeile 3: vollständig leere Zeile +Objektzeilen mit IfcGUID: 897 +Summen-/Gruppierungszeilen ohne IfcGUID: 211 +Nichtleere doppelte IfcGUIDs: keine +``` + +Beispiel einer nicht als Objekt zu importierenden Summenzeile: + +```text +"UV_AV_DG_01-1F1: 8";"";"";"";"";"";"";"";"";"";"";"" +``` + +Die Implementierung darf daher nicht annehmen, dass: + +- die Kopfzeile immer die erste Dateizeile ist; +- jede Zeile nach der Kopfzeile ein Objekt ist; +- jede fehlende `IfcGUID` automatisch ein fehlerhaftes Objekt darstellt. + +### 6.3 Erkennen der Kopfzeile + +**Implementierungsvorschlag** + +Die Kopfzeile wird anhand der projektweit konfigurierten erforderlichen Spaltennamen gesucht. + +- Genau eine passende Kopfzeile: verwenden. +- Keine passende Kopfzeile: Import blockieren. +- Mehrere passende Kopfzeilen: Import blockieren und Auswahl beziehungsweise Konfiguration verlangen. +- Zeilen vor und nach der Kopfzeile bleiben als Transportbestandteil erhalten. + +### 6.4 Zeilenklassen + +**Implementierungsvorschlag** + +Jede CSV-Zeile wird genau einer Klasse zugeordnet: + +1. `metadata` + Zeile vor der Kopfzeile oder sonstige nicht tabellarische Metadaten. + +2. `header` + Erkannte Kopfzeile. + +3. `passthrough` + Leer-, Summen-, Gruppierungs- oder sonstige Zeile ohne externe Objektidentität, die nicht wie ein unvollständiges Objekt wirkt. + +4. `object` + Zeile mit nichtleerer, syntaktisch akzeptierter `IfcGUID`. + +5. `suspect-object` + Zeile ohne `IfcGUID`, aber mit befüllten objekttragenden Feldern wie Raum, Familie und Typ oder Auswahlkenner. + +`passthrough`-Zeilen werden unverändert erhalten und erzeugen keine externen Objekte. + +`suspect-object`-Zeilen werden als Konflikt angezeigt und nicht automatisch verworfen. + +### 6.5 Strukturtreuer Rückexport + +**Bestätigte Anforderung** + +Der Rückexport basiert auf der zuletzt akzeptierten vollständigen CSV und muss erhalten: + +- identische Anzahl und Reihenfolge der Zeilen; +- identische Anzahl und Reihenfolge der Spalten; +- Titel-, Leer-, Summen- und sonstige Passthrough-Zeilen; +- unbekannte beziehungsweise nicht gemappte Spalten; +- unveränderte Werte in nicht zu schreibenden Zellen; +- `IfcGUID` jeder Objektzeile; +- vorhandene Transportmerkmale, soweit technisch zuverlässig erkennbar. + +Mindestens zu erhalten sind: + +```text +BOM-Status +Encoding +Zeilenende +Trennzeichen +Spaltenreihenfolge +Zeilenreihenfolge +Titelzeilen +Leerzeilen +Summenzeilen +``` + +Nur explizit als Ausgangsparameter gemappte Zellen von erkannten Objektzeilen dürfen ergänzt oder überschrieben werden. + +Eine vollständig neu zusammengestellte Tabelle mit nur den fachlich verwendeten Spalten ist unzulässig. + +### 6.6 Anonymisiertes Beispiel + +```csv +"Projekt-Export";"";"";"";"" +"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"Auswahlkenner";"Leistung";"IfcGUID" +"";"";"";"";"";"";"" +"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Doppelsteckdose";"Arbeitsplatz";"";"0AbCdEfGhIjKlMnOpQrStu" +"UV_AV_01-2F1: 2";"";"";"";"";"";"" +``` + +Die letzte Zeile ist eine Passthrough-Summenzeile, kein externes Modellobjekt. + +## 7. Identität und Matching + +### 7.1 Externe Identität + +**Bestätigte Anforderung** + +- `IfcGUID` ist der stabile Schlüssel für ein Revit-Objekt. +- Ein Objekt bleibt auch bei geänderten Quellwerten über seine `IfcGUID` bekannt. +- Doppelte nichtleere `IfcGUIDs` innerhalb eines Imports sind ein blockierender Konflikt. +- Eine fehlende `IfcGUID` in einer echten Objektzeile verhindert die automatische Verknüpfung. + +### 7.2 Quellmodell + +**Bestätigte Anforderung plus Architekturfolgerung** + +Für die erste Ausbaustufe gibt es pro Leistungsbilanzprojekt genau einen vollständigen Revit-Modellstrom. + +Die technische Architektur darf trotzdem eine implizite externe Quelle vorsehen, damit `IfcGUID` nicht global oder projektübergreifend eindeutig sein muss. + +Empfohlene Semantik: + +```text +Project +└── implicit ExternalModelSource + └── ExternalModelObject identified by IfcGUID +``` + +Es gibt in der ersten Ausbaustufe keine sichtbare Verwaltung von Hauptmodellen, Teilmodellen oder verknüpften Modellen. + +### 7.3 Interne Identität + +Jedes externe Objekt erhält zusätzlich eine stabile interne UUID. + +Diese UUID wird für interne Relationen, Commands, Snapshots und Undo/Redo verwendet. + +## 8. Datenhoheit + +### 8.1 Revit-geführt + +Revit ist zunächst führend für: + +- Existenz des externen Objekts; +- `IfcGUID`; +- Familie und Typ; +- Revit-Raumnummer und Revit-Raumname als Quellwerte; +- CAx-Auswahlkenner als Quellwert; +- sonstige ausschließlich aus Revit importierte Attribute. + +### 8.2 Leistungsbilanz-geführt + +Leistungsbilanz ist nach Bestätigung führend für: + +- interne Raum- und Ebenenverknüpfung; +- Standard- und Objektverteilung; +- Klassifizierung; +- `ProjectDevice`-Verknüpfung; +- lokale Planungswerte; +- Stromkreiszuordnung; +- `CircuitDeviceRow`-Zuordnung; +- Stromkreis-BMK; +- elektrische Planungsleistung; +- GZF, cosPhi, Kostengruppe, Kategorie und weitere lokale Gerätewerte. + +### 8.3 Konfliktregel + +Kein Folgeimport darf lokale Planungswerte automatisch überschreiben. + +Der Nutzer entscheidet je Konflikt oder per Sammelaktion zwischen: + +- Revit-Änderung übernehmen; +- lokalen Wert beibehalten; +- manuell einen dritten Wert festlegen. + +## 9. Projektweite Revit-Konfiguration + +### 9.1 Mapping + +Die Projektkonfiguration muss die CSV-Spalten auf interne Bedeutungen abbilden. + +Der CAx-Auswahlkenner muss mindestens als sichtbarer, filterbarer und gruppierbarer Wert verfügbar sein. Er kann als Vorschlag für einen Anzeigenamen verwendet werden. + +### 9.2 Familie-und-Typ-Regeln + +**Bestätigte Anforderung** + +In der ersten Ausbaustufe gilt ausschließlich exakter Textvergleich. + +Eine Regel kann mindestens festlegen: + +- exakter CSV-Wert `Familie und Typ`; +- interner Gerätetyp beziehungsweise fachliche Kategorie; +- Anschlussart; +- ProjectDevice-Kategorie `lighting`, `single_phase` oder `three_phase`; +- Mengenregel: + - feste Menge `1`; + - Menge aus gemappter CSV-Spalte; +- optional vorgeschlagener Anzeigename beziehungsweise Mappingquelle. + +Mehrere unterschiedliche Revit-Familien und -Typen dürfen demselben internen Gerätetyp zugeordnet werden. + +Beispiele: + +```text +_E_CAx Steckdose_AP: Steckdose Aufputz +→ interner Gerätetyp Steckdose +→ single_phase +→ Menge 1 +``` + +```text +_E_CAx Mehrfachsteckdose: Doppelsteckdose +→ interner Gerätetyp Steckdose +→ single_phase +→ Menge aus konfigurierter Anzahl-Spalte +``` + +### 9.3 Zugriff + +Der Konfigurationsbereich ist zunächst für alle Nutzer zugänglich, da noch kein Rollen- oder Rechtesystem existiert. + +## 10. Externes Objekt und Planungszustand + +### 10.1 Notwendige fachliche Informationen + +**Implementierungsvorschlag, kein finales Schema** + +Ein persistiertes externes Objekt benötigt mindestens: + +- interne UUID; +- Projekt- und implizite Quellenzugehörigkeit; +- `IfcGUID`; +- Referenz auf die aktuelle beziehungsweise zuletzt akzeptierte Quellzeile; +- importierte Quellwerte; +- zuletzt akzeptierte Quellwerte für den Folgeabgleich; +- verknüpften internen Raum, optional; +- Standardverteilung des Raums; +- abweichende Objektverteilung, optional; +- Klassifizierung; +- wirksame Menge; +- lokale Planungswerte; +- lokale Override-Metadaten; +- optionales `linkedProjectDeviceId`; +- optionalen Link zu einer `CircuitDeviceRow`; +- letzten Sichtbarkeits-/Importstatus; +- letzten gesehenen Importbatch; +- Konfliktstatus. + +### 10.2 Kein automatischer Row-Lebenszyklus + +Folgende Aktionen erzeugen keine `CircuitDeviceRow`: + +- CSV einlesen; +- Raum zuordnen; +- Verteilung zuordnen; +- klassifizieren; +- ProjectDevice verknüpfen; +- lokale Planungswerte bearbeiten. + +Eine Gerätezeile entsteht ausschließlich durch ausdrückliche Zuordnung im Stromkreislisteneditor. + +## 11. ProjectDevice-Verknüpfung + +### 11.1 Vorhandenes ProjectDevice + +Importierte Objekte dürfen mit einem vorhandenen `ProjectDevice` verknüpft werden. + +Das vorhandene `ProjectDevice` wird durch Revit-Werte niemals automatisch verändert. + +Die bestehende Regel bleibt verbindlich: + +- `ProjectDevice` ist die kanonische Projektvorlage; +- lokale Werte und Overrides bleiben möglich; +- Änderungen am ProjectDevice werden nie still synchronisiert; +- Synchronisierung benötigt Vorschau und Auswahl. + +### 11.2 Neues ProjectDevice aus Importobjekten + +**Bestätigte Anforderung** + +Der Nutzer darf im Importwizard ein neues `ProjectDevice` erstellen. + +Das Erstellformular darf anhand der gemappten Werte der ausgewählten Revit-Objekte vorausgefüllt werden. + +Mögliche Vorbelegungen: + +- `name`; +- `displayName`; +- Kategorie; +- Anschlussart; +- Kostengruppe; +- Einzelleistung; +- Gleichzeitigkeitsfaktor; +- cosPhi; +- Bemerkung. + +Die Kategorie des ProjectDevices bestimmt gemäß bestehendem Modell die Phasenart. Es darf kein paralleles, widersprüchliches Phasenmodell eingeführt werden. + +Bei einer Mehrfachauswahl mit unterschiedlichen Quellwerten muss die UI Abweichungen anzeigen. Sie darf nicht willkürlich einen Wert wählen. + +### 11.3 Lokale Werte vor der Stromkreiszuordnung + +Importierte Objekte dürfen bereits vor der Stromkreiszuordnung lokale Planungswerte und lokale Abweichungen vom verknüpften ProjectDevice besitzen. + +Beim späteren Erzeugen einer Gerätezeile werden diese bestätigten Objektplanungswerte als Ausgangswerte verwendet. + +## 12. Erstimport-Workflow + +### 12.1 Öffnen und Konfiguration + +1. Nutzer öffnet die projektweite Revit-Integration. +2. Falls keine gültige Mappingkonfiguration existiert, wird zuerst die Konfiguration geöffnet. +3. Nutzer lädt eine vollständige CSV hoch. +4. Parser erkennt Transportparameter und Kopfzeile. +5. Datei wird ohne fachliche Projektschreibvorgänge geparst und validiert. + +### 12.2 Vorschau + +Die Vorschau zeigt mindestens: + +- Anzahl erkannter Objektzeilen; +- Anzahl Passthrough-Zeilen; +- Anzahl verdächtiger Objektzeilen; +- fehlende und doppelte `IfcGUIDs`; +- unbekannte Familie-und-Typ-Werte; +- neue und bereits bekannte Räume; +- vorhandene Stromkreiswerte; +- nicht parsebare Leistungen oder Mengen. + +### 12.3 Raumwizard + +Räume werden aus den importierten Objektzeilen abgeleitet. + +Der Nutzer kann: + +- einen neuen internen Raum anlegen; +- einen importierten Raum mit einem vorhandenen internen Raum verknüpfen; +- mehrere Räume sortiert nach Raumnummer bearbeiten; +- Räume einer Ebene zuordnen. + +Die Raumnummer ist zunächst das primäre Matchingmerkmal. + +Eine eigene Revit-Raum-GUID ist nicht erforderlich. + +### 12.4 Verteilungen + +Verteilungen werden manuell angelegt. + +Der Nutzer kann: + +- einem Raum eine Standardverteilung zuweisen; +- mehrere Räume gesammelt einer Verteilung zuweisen; +- einzelne Objekte abweichend einer anderen Verteilung zuweisen; +- Objekte ohne MEP-Raum manuell einer Verteilung zuweisen. + +Neue Objekte eines bereits bekannten Raums erhalten die bestehende Standardverteilung als Vorschlag. + +### 12.5 Klassifizierung und ProjectDevices + +Der Nutzer kann: + +- Objekte nach Raum, Familie und Typ, CAx-Auswahlkenner und Status filtern; +- Familie-und-Typ-Werte klassifizieren; +- mehrere Objekte mit einem vorhandenen ProjectDevice verknüpfen; +- unmittelbar ein neues ProjectDevice erstellen; +- lokale Werte bearbeiten. + +### 12.6 Bereits gefüllte Stromkreiskennzeichnung + +Enthält eine Objektzeile bereits eine Stromkreiskennzeichnung, muss der Wizard fragen, wie sie behandelt wird. + +Möglichkeiten: + +- vorhandenen Wert nur als Quellwert behalten; +- vorhandene Zuordnung übernehmen; +- vorhandenen Wert ignorieren. + +Eine Übernahme ist nur zulässig, wenn: + +- die Präfix-Verteilung eindeutig vorhanden ist; +- die Zuordnung zur Raum- beziehungsweise Objektverteilung passt oder ausdrücklich bestätigt wird; +- der Stromkreis eindeutig vorhanden oder bewusst anzulegen ist; +- keine widersprüchliche Zuordnung entsteht. + +Keine Variante darf still gewählt werden. + +### 12.7 Übernahme + +Die bestätigte Erstübernahme schreibt mindestens: + +- Importbatch; +- vollständige Transportgrundlage für den Rückexport; +- externe Objekte; +- Quellwerte; +- Raum- und Verteilungslinks; +- Klassifizierungen; +- ProjectDevice-Links; +- lokale Planungswerte; +- Konfliktentscheidungen. + +Die Übernahme ist ein atomarer Projekt-Command mit `expectedRevision`. + +Sie erzeugt genau eine Projektrevision und einen Undo-Schritt. + +Nach erfolgreicher Übernahme erscheinen ausreichend klassifizierte, einer Verteilung zugeordnete und noch nicht verschaltete Objekte im Stromkreislisteneditor. + +## 13. UI 1 – Import-, Staging- und Abgleichswizard + +### 13.1 Pflichtfunktionen + +- sortierbare tabellarische Darstellung für mehrere Tausend Objekte; +- Volltextsuche; +- Filter nach Raum, Raumname, Familie und Typ, CAx-Auswahlkenner, Verteilung, ProjectDevice, Stromkreis und Status; +- Mehrfachauswahl; +- Shift-Bereichsauswahl; +- Auswahl aller gefilterten Treffer; +- Auswahl vollständiger Räume oder Gruppen; +- Sammelzuordnung zu Raum, Ebene, Verteilung, Klassifizierung und ProjectDevice; +- Erstellen eines ProjectDevices; +- Bearbeiten lokaler Planungswerte; +- Vergleich Quellwert gegen lokalen Wert; +- Konfliktentscheidungen einzeln und gesammelt; +- Vorschau des Änderungssatzes vor Übernahme. + +### 13.2 Sichtbare Objektinformationen + +Mindestens: + +- `IfcGUID`; +- Familie und Typ; +- CAx-Auswahlkenner; +- Revit-Raumnummer und Raumname; +- interner Raum und Ebene; +- Standardverteilung; +- tatsächliche Objektverteilung; +- Klassifizierung; +- wirksame Menge; +- verknüpftes ProjectDevice; +- lokaler Anzeigename; +- lokale Leistung; +- zugeordneter Stromkreis; +- zugeordnete CircuitDeviceRow; +- Import- und Konfliktstatus. + +### 13.3 Statusfilter + +Mindestens: + +- neu; +- bekannt und unverändert; +- geändert; +- Raum geändert; +- im vollständigen Folgeimport nicht enthalten; +- ohne Raum; +- ohne Verteilung; +- ohne Klassifizierung; +- ohne ProjectDevice; +- ohne Stromkreis; +- vollständig zugeordnet; +- konfliktbehaftet. + +`Ohne ProjectDevice` ist kein Fehler. + +## 14. UI 2 – Unzugeordnete Objekte im Stromkreislisteneditor + +### 14.1 Sichtbarkeit + +Der Editor zeigt einen separaten Bereich beziehungsweise Drawer mit Objekten, die: + +- der aktuellen Verteilung zugeordnet sind; +- noch mit keiner `CircuitDeviceRow` verknüpft sind; +- ausreichend klassifiziert sind; +- keinen blockierenden Konflikt besitzen. + +### 14.2 Darstellung und Filter + +Mindestens sichtbar: + +- Raum; +- Familie und Typ; +- CAx-Auswahlkenner; +- interner Gerätetyp; +- ProjectDevice; +- wirksame Menge; +- Einzelleistung; +- Objektgesamtleistung; +- Kategorie beziehungsweise Phasenart; +- Anschlussart; +- Status. + +Filter und Mehrfachauswahl müssen auch hier für große Mengen geeignet sein. + +### 14.3 Drag-and-drop-Semantik + +**Bestätigte Anforderung** + +Der Drop-Zielbereich bestimmt die fachliche Aktion. + +#### Drop auf bestehende CircuitDeviceRow + +- ausgewählte externe Objekte werden mit genau dieser Zeile verknüpft; +- Menge und Gesamtleistung werden neu berechnet; +- die Anwendung wählt niemals selbst eine andere passende Zeile. + +#### Drop auf bestehenden Circuit + +- in diesem Stromkreis wird eine neue CircuitDeviceRow angelegt; +- dies gilt auch, wenn bereits eine ähnliche Zeile vorhanden ist. + +#### Drop auf freien Zielbereich für neuen Circuit + +- ein neuer Circuit wird über den bestehenden Circuit-Insert-/Move-Mechanismus angelegt; +- mindestens eine neue CircuitDeviceRow wird erzeugt; +- BMK-Erzeugung folgt den bestehenden expliziten Regeln des Editors; +- keine Nachbarstromkreise werden neu nummeriert. + +Die gesamte jeweilige Aktion einschließlich externer Links, Mengenänderung, Circuit-/Row-Erzeugung, Revision und History-Transition ist atomar. + +### 14.4 Keine automatische Zusammenführung + +Gleiche Eigenschaften dürfen Hinweise und Vorschläge erzeugen. + +Die Anwendung darf externe Objekte aber nicht ohne Benutzeraktion automatisch: + +- zusammenführen; +- einer bestehenden Gerätezeile hinzufügen; +- in einen Stromkreis verschieben; +- auf mehrere Gerätezeilen verteilen. + +## 15. Gerätezeilen, Aggregation und Mehrfachsteckdosen + +### 15.1 Einzelobjekte bleiben erhalten + +Jedes Revit-Objekt bleibt intern separat anhand seiner `IfcGUID` nachvollziehbar, auch wenn mehrere Objekte in einer Gerätezeile aggregiert dargestellt werden. + +### 15.2 Mehrfachsteckdose + +**Bestätigte Anforderungen** + +- Eine Mehrfachsteckdose ist ein Revit-Objekt. +- Ihr Mengenparameter enthält die Anzahl physischer Schukosteckdosen. +- Eine Doppelsteckdose zählt als zwei Steckdosen. +- Das Objekt ist unteilbar und darf nicht auf mehrere Stromkreise verteilt werden. +- Die Leistung wird je physischer Steckdose angesetzt. +- Die Objektgesamtleistung ist `wirksame Menge × powerPerUnit`. + +### 15.3 Gerätezeilenmenge + +Die Menge einer CircuitDeviceRow ist die Summe der wirksamen Mengen aller mit ihr verknüpften externen Objekte plus gegebenenfalls manuell geführter Anteile, sofern das vorhandene Datenmodell dies eindeutig unterscheiden kann. + +Beispiel: + +```text +2 Doppelsteckdosen + 2 Einzelsteckdosen +→ 4 ExternalModelObjects +→ CircuitDeviceRow.quantity = 6 +``` + +### 15.4 Getrennte Gerätezeilen + +**Bestätigte Anforderungen** + +Unterschiedliche Räume werden in getrennten Gerätezeilen dargestellt. + +Unterschiedliche CAx-Auswahlkenner werden in getrennten Gerätezeilen dargestellt. + +Beispiel: + +```text +Circuit UV_AV_01-2F1 +- Raum 01/101: 4 × Arbeitsplatzsteckdose +- Raum 01/101: 2 × Mediensteckdose +- Raum 01/102: 1 × Arbeitsplatzsteckdose +``` + +Der Nutzer darf trotz gleicher Werte bewusst mehrere getrennte Gerätezeilen erzeugen. + +### 15.5 Raumübergreifende Stromkreise + +Raumübergreifende Stromkreise bleiben manuell zulässig und werden über mehrere Gerätezeilen übersichtlich dargestellt. + +Die spätere automatische Stromkreisvorschlagsfunktion darf nur raumbezogene Vorschläge erzeugen. + +## 16. Folgeimport + +### 16.1 Annahme + +Jeder Folgeimport enthält erneut den vollständigen relevanten Modellstand der Kategorie `Elektroinstallation`. + +Teilimporte sind in der ersten Ausbaustufe nicht zulässig beziehungsweise werden nicht als Löschgrundlage verwendet. + +### 16.2 Vergleichszustände + +Mindestens zu erkennen: + +- bekannte `IfcGUID`, unveränderte Quellwerte; +- neue `IfcGUID`; +- bekannte `IfcGUID` mit geänderten Quellwerten; +- geänderter Revit-Raum; +- geänderte Stromkreiskennzeichnung; +- geänderte Leistung; +- zuvor bekannte `IfcGUID` fehlt; +- doppelte `IfcGUID`; +- verdächtige Objektzeile ohne `IfcGUID`. + +### 16.3 Fehlende Objekte + +Fehlt eine bekannte `IfcGUID`, wird das Objekt nur als `nicht mehr im aktuellen vollständigen Export enthalten` markiert. + +Es wird nicht automatisch: + +- gelöscht; +- aus seiner Gerätezeile entfernt; +- aus seinem Stromkreis entfernt; +- von seinem ProjectDevice getrennt. + +Erst nach ausdrücklicher Bestätigung wird das externe Objekt entfernt beziehungsweise als gelöscht übernommen. + +Danach: + +- wird sein Link zur CircuitDeviceRow entfernt; +- werden Menge und Leistung der Zeile neu berechnet; +- bleibt ein dadurch leer gewordener Circuit bestehen; +- wird seine BMK nicht automatisch freigegeben oder neu vergeben. + +### 16.4 Raumänderung + +Eine Raumänderung wird anhand derselben `IfcGUID` erkannt und als Konflikt angezeigt. + +Sie wird nicht automatisch übernommen. + +### 16.5 Alle Änderungen sind nutzergesteuert + +Der Nutzer kann Konflikte: + +- einzeln behandeln; +- gruppiert behandeln; +- Revit-Werte übernehmen; +- lokale Werte behalten; +- manuell überschreiben. + +Die bestätigte Übernahme eines Folgeimports ist ein atomarer Projekt-Command und eine Projektrevision. + +## 17. Rückexport + +### 17.1 Grundlage + +Der Export verwendet den zuletzt akzeptierten vollständigen Importbatch und eine bekannte aktuelle Projektrevision. + +Ein Export verändert die Projektrevision nicht. + +### 17.2 Stromkreiskennzeichnung + +Für jedes einem Stromkreis zugeordnete externe Objekt wird geschrieben: + +```text + +``` + +Beispiel: + +```text +UV_AV_DG_01-2F1 +``` + +Die genaue Trennlogik folgt dem tatsächlich im Projekt verwendeten Verteilerpräfix und Stromkreis-BMK; es darf kein zweites unabhängiges Nummerierungssystem eingeführt werden. + +### 17.3 Leistung + +Die Rückgabe erfolgt in Watt. + +Für eine Mehrfachsteckdose wird die objektbezogene Gesamtleistung geschrieben: + +```text +Objektleistung = wirksame Menge des Revit-Objekts × powerPerUnit +``` + +Bei normalen Einzelobjekten ist die wirksame Menge `1`. + +### 17.4 Objektbezogene Ausgabe + +Sind mehrere Revit-Objekte mit einer gemeinsamen CircuitDeviceRow verknüpft: + +- erhält jedes Objekt seine eigene ursprüngliche CSV-Zeile; +- erhält jedes Objekt dieselbe Stromkreiskennzeichnung; +- erhält jedes Objekt seine eigene Objektgesamtleistung; +- bleibt seine `IfcGUID` unverändert. + +### 17.5 Exportnachweis + +**Implementierungsvorschlag** + +Ein Exportbatch speichert mindestens: + +- Projekt-ID; +- Projektrevision; +- zugrunde liegenden Importbatch; +- Zeitstempel; +- Dateiname; +- Prüfsumme; +- verwendete Mappingkonfiguration beziehungsweise deren Version. + +Dies ist Auditmetadaten und kein Ersatz für die Projekt-Revisionshistorie. + +## 18. Validierungen + +### 18.1 Blockierende Fehler + +Mindestens: + +- CSV kann mit der Konfiguration nicht geparst werden; +- erforderliche Kopfzeile ist nicht eindeutig; +- erforderliche Spalte fehlt; +- doppelte nichtleere `IfcGUID` unter Objektzeilen; +- ungültige oder nicht serialisierbare Projektwerte; +- stale `expectedRevision`; +- Verknüpfung zu einem anderen Projekt; +- Drop-Ziel gehört nicht zur zugeordneten Verteilung beziehungsweise Liste; +- Mehrfachsteckdose würde intern auf mehrere Stromkreise aufgeteilt; +- atomare Command-Vorbedingungen stimmen nicht mehr. + +### 18.2 Warnungen beziehungsweise aufzulösende Konflikte + +Mindestens: + +- unbekannter Familie-und-Typ-Wert; +- Objekt ohne Raum; +- Raumänderung; +- bestehende abweichende Stromkreiskennzeichnung; +- abweichende Revit-Leistung; +- Objekt im Folgeimport nicht enthalten; +- ausgewählte Objekte besitzen unterschiedliche lokale Planungswerte; +- vorhandene Stromkreiszuordnung passt nicht zur Verteilung; +- verdächtige Objektzeile ohne `IfcGUID`. + +## 19. Revision, Undo/Redo und Audit + +### 19.1 Gemeinsame Transaktionsgrenze + +**Bestehende Architekturvorgabe** + +Alle bestätigten projektbezogenen Änderungen laufen über: + +```text +domain mutation ++ inverse command ++ project revision ++ undo/redo transition +``` + +Sie werden gemeinsam über die bestehende Projekt-Command-Transaktionsgrenze committed oder vollständig zurückgerollt. + +Neue Revit-Fachlogik darf nicht direkt von React, `better-sqlite3`, globalen Datenbankinstanzen oder konkreten SQLite-Repositories abhängen. + +### 19.2 Neue logische Commands + +**Implementierungsvorschlag** + +Mindestens getrennte Command-Verantwortlichkeiten: + +- Revit-Projektkonfiguration ändern; +- akzeptierten Erstimport anwenden; +- akzeptierten Folgeimport anwenden; +- externe Objektplanungswerte ändern; +- externe Objekte mit ProjectDevices verknüpfen oder trennen; +- externe Objekte einer bestehenden CircuitDeviceRow zuweisen; +- externe Objekte als neue CircuitDeviceRow einem Circuit zuweisen; +- externe Objekte mit neuem Circuit und neuer Row zuweisen; +- externe Objektzuordnung aus einer Row lösen; +- externes Objekt nach bestätigtem Fehlen löschen. + +Die konkreten Command-Namen und Payloads sind beim Implementierungsaudit festzulegen. + +### 19.3 Undo + +Undo muss insbesondere wiederherstellen: + +- externe Objekte und Quellstände; +- Raum-, Verteilungs- und ProjectDevice-Links; +- lokale Planungswerte und Overrides; +- CircuitDeviceRow-Verknüpfungen; +- Mengen und Leistungen; +- erzeugte Row- und Circuit-UUIDs; +- Importstatus. + +Undo darf keine BMKs neu berechnen. + +## 20. Snapshots und portabler Projekttransfer + +**Bestehende Architekturfolgerung** + +Sobald die Revit-Integration unterstützt wird, gehört ihr bestätigter Projektzustand zum vollständigen logischen Projektzustand. + +Snapshot- und JSON-Transfer-Schema müssen deshalb nach einem bewussten Schema-Versionswechsel mindestens erhalten: + +- projektweite Revit-Konfiguration; +- Mappingregeln; +- Familie-und-Typ-Regeln; +- externe Objekte; +- Quell- und Planungswerte; +- Raum-, Verteilungs-, ProjectDevice- und CircuitDeviceRow-Links; +- relevante Importbatch-Grundlagen für den Rückexport; +- Konflikt- und Sichtbarkeitsstatus. + +Beim Duplizieren eines Projekts: + +- werden alle internen UUIDs remapped; +- bleiben externe `IfcGUIDs` als Quellidentitäten erhalten; +- werden interne Links auf die remappten Projektobjekte aktualisiert. + +Vorhandene Snapshot-Migrationen dürfen nicht still verändert werden. Es ist eine additive neue Schema-Version erforderlich. + +## 21. Modul- und Persistenzgrenzen + +### 21.1 Verbindliche Grenzen + +- Parsing und Serialisierung sind keine React-Aufgabe. +- CSV-Dialektlogik ist kein Bestandteil von Circuit-Domain-Services. +- Domain-Services kennen keine konkreten SQLite-Repositories. +- Controller importieren keinen globalen Datenbankclient. +- Writes erfolgen ausschließlich über typisierte Commands. +- Reads verwenden schmale, fachlich benannte Repositories. +- Server-Komposition erzeugt und injiziert konkrete Adapter. +- SQLite bleibt der unterstützte Adapter. +- PostgreSQL wird nicht in dieser Phase eingeführt. + +### 21.2 Empfohlene Verantwortlichkeiten + +**Implementierungsvorschlag** + +```text +CSV transport adapter +- dialect detection +- header and row classification +- lossless-enough matrix preservation +- mapped value parsing +- return serialization + +External model domain +- identity and matching +- source versus planning values +- conflict calculation +- object lifecycle +- aggregation invariants + +Application services / commands +- import preview +- first import apply +- follow import apply +- link and unlink operations +- export projection + +Persistence adapters +- project configuration +- import/export batches +- external objects +- links and accepted source state + +Frontend +- project configuration UI +- staging wizard +- unassigned-object editor drawer +``` + +Die Namen sind Verantwortlichkeiten, keine vorab beschlossenen Tabellen- oder Ordnernamen. + +## 22. Performanceanforderungen + +**Bestätigte Anforderung** + +Das UI muss mehrere Tausend Objektzeilen praktisch bearbeitbar machen. + +Mindestens: + +- keine vollständige DOM-Darstellung aller Zeilen, falls dies die Bedienbarkeit beeinträchtigt; +- server- oder clientseitige effiziente Sortierung und Filterung; +- stabile Mehrfachauswahl; +- Sammelaktionen auf gefilterte Treffer; +- keine fachliche Änderung beim bloßen Filtern und Sortieren; +- keine automatische Neunummerierung; +- Import und Export dürfen zunächst synchron laufen, solange reale Projektdateien zuverlässig verarbeitet werden. + +Die Referenzdatei mit rund 900 Objektzeilen und über 1.100 Gesamtzeilen ist als Mindest-Integrationstest zu verwenden. + +## 23. Implementierungsphasen + +### Phase 14.0 – Audit und Verträge + +Ziel: + +- aktuelle ProjectDevice-, Row-, Command-, Snapshot- und Editorpfade prüfen; +- konkretes Datenmodell und Command-Schnittstellen vorschlagen; +- keine Implementierung vor Freigabe des Auditberichts. + +Lieferung: + +- Keep/Refactor/Add-Liste; +- vorgeschlagene Domainobjekte; +- vorgeschlagene Commands; +- Snapshot-/Transfer-Auswirkungen; +- Risiken bei Row-Mengen und lokalen Objektwerten. + +Abnahme: + +- keine bestehende Architekturregel wird umgangen; +- offene Punkte sind klar als solche markiert. + +### Phase 14.1 – CSV-Konfiguration und Parser + +Ziel: + +- projektweite Mappingkonfiguration; +- Parser und Serializer für das beobachtete CSV-Profil; +- reine Vorschau ohne Projektschreibvorgang. + +Abnahme: + +- Referenzdatei wird mit 897 Objektzeilen und 211 Passthrough-Summenzeilen erkannt; +- Titel-, Leer- und Summenzeilen bleiben erhalten; +- nichtleere `IfcGUIDs` sind eindeutig; +- Rückserialisierung ohne fachliche Änderungen erhält Struktur und Zellenwerte; +- Parser besitzt fokussierte Tests für BOM, CRLF, Semikolon, Quotierung und Kopfzeile in Zeile 2. + +### Phase 14.2 – Externe Objekte und Erstimport + +Ziel: + +- persistente externe Objekte; +- Raum-, Ebenen-, Verteilungs-, Klassifizierungs- und ProjectDevice-Verknüpfung; +- atomare Importübernahme. + +Abnahme: + +- Import erzeugt keine CircuitDeviceRow; +- Raum- und Verteilungszuordnung kann gespeichert werden; +- neues ProjectDevice kann vorausgefüllt erstellt werden; +- vorhandenes ProjectDevice wird nicht durch Revit-Werte verändert; +- Import ist ein Undo-Schritt; +- Undo entfernt den vollständigen bestätigten Importzustand. + +### Phase 14.3 – Stromkreislisteneditor + +Ziel: + +- unzugeordnete externe Objekte je Verteilung; +- Drag-and-drop zu Row, Circuit oder neuem Circuit; +- Aggregation und Mehrfachsteckdosen. + +Abnahme: + +- Drop auf Row ergänzt genau diese Row; +- Drop auf Circuit erzeugt eine neue Row; +- Drop auf neuen Zielbereich erzeugt Circuit und Row atomar; +- 2 Doppel- plus 2 Einzelsteckdosen ergeben Menge 6; +- keine Mehrfachsteckdose kann geteilt werden; +- verschiedene Räume und Auswahlkenner bleiben in getrennten Rows; +- Undo stellt externe Links, Mengen, Row-/Circuit-UUIDs und BMKs exakt wieder her. + +### Phase 14.4 – Rückexport + +Ziel: + +- strukturtreue Revit-Rückgabe-CSV. + +Abnahme: + +- Zeilen- und Spaltenzahl sowie Reihenfolge entsprechen der Ausgangsdatei; +- unbekannte Spalten und Passthrough-Zeilen bleiben erhalten; +- Stromkreis und Leistung werden ausschließlich in gemappte Objektzellen geschrieben; +- jedes Objekt behält seine `IfcGUID`; +- Mehrfachsteckdosen erhalten ihre Objektgesamtleistung in Watt; +- Export referenziert eine bekannte Projektrevision. + +### Phase 14.5 – Folgeimport und Konflikte + +Ziel: + +- additions, changes, missing objects and conflicts; +- Einzel- und Sammelentscheidungen. + +Abnahme: + +- neue Objekte werden erkannt; +- Raumänderungen werden konfliktbehaftet angezeigt; +- fehlende Objekte werden nicht automatisch entfernt; +- lokale Werte werden nicht automatisch überschrieben; +- bestätigte Änderungen sind ein atomarer Undo-Schritt; +- unveränderte Objekte erzeugen keine unnötigen Domainänderungen. + +### Phase 14.6 – Snapshot, Transfer und Härtung + +Ziel: + +- vollständige Projektportabilität; +- Performance und End-to-End-Tests. + +Abnahme: + +- Snapshot/Restore erhält sämtliche externe Links und Exportgrundlagen; +- Projektduplikat remapped interne UUIDs und behält `IfcGUIDs`; +- Undo/Redo funktioniert nach Reload; +- Referenz-CSV durchläuft Erstimport, Row-Zuordnung, Export, Folgeimport und Restore; +- vollständige Test- und Build-Suite läuft erfolgreich. + +## 24. End-to-End-Abnahmeszenarien + +### Szenario A – Gemischte Steckdosen + +Aus Revit: + +```text +Raum 01/101 +- 2 Doppelsteckdosen, jeweils Menge 2 +- 2 Einzelsteckdosen, jeweils Menge 1 +- gleicher CAx-Auswahlkenner +``` + +Erwartung: + +- 4 externe Objekte; +- wirksame Menge 6; +- Nutzer zieht alle Objekte auf eine neue Row; +- Row zeigt `6 × Steckdose`; +- alle vier CSV-Zeilen erhalten denselben Stromkreis; +- Doppelsteckdosen erhalten jeweils `2 × powerPerUnit` als Objektleistung; +- Einzelsteckdosen erhalten jeweils `1 × powerPerUnit`. + +### Szenario B – Gleiches ProjectDevice in mehreren Räumen + +Aus Revit: + +- gleicher Leuchtentyp in drei Technikräumen. + +Erwartung: + +- Nutzer wählt alle Leuchten raumübergreifend; +- erstellt ein neues, aus Revit-Werten vorausgefülltes ProjectDevice; +- alle Objekte werden damit verknüpft; +- es wird noch keine CircuitDeviceRow erzeugt; +- später entstehen je Raum getrennte Rows, sofern die Objekte verschiedenen Räumen zugeordnet werden. + +### Szenario C – Raumübergreifender Putzsteckdosenstromkreis + +- mehrere Räume mit jeweils wenigen Putzsteckdosen; +- Nutzer ordnet sie manuell demselben Circuit zu; +- pro Raum entsteht eine getrennte Row; +- alle Objekte erhalten dieselbe Stromkreiskennzeichnung. + +### Szenario D – Folgeimport mit Raumänderung + +- bekannte `IfcGUID`; +- Revit-Raumnummer ist geändert. + +Erwartung: + +- Objekt wird nicht neu angelegt; +- Raumänderung wird als Konflikt angezeigt; +- bestehende interne Raum-, Verteilungs- und Stromkreiszuordnung bleibt unverändert; +- Nutzer kann die Änderung einzeln oder gesammelt übernehmen. + +### Szenario E – Fehlendes Objekt + +- eine zuvor bekannte `IfcGUID` fehlt im vollständigen Folgeimport. + +Erwartung: + +- Objekt wird als fehlend markiert; +- Row-Menge und Leistung bleiben zunächst unverändert; +- erst bestätigte Löschung entfernt Link und Mengenanteil; +- Circuit bleibt auch bei leerer Row beziehungsweise nach Row-Entfernung bestehen; +- BMK wird nicht automatisch wiederverwendet. + +### Szenario F – Bestehende Stromkreiskennzeichnung beim Erstimport + +- Revit-Zeile enthält bereits `UV_AV_01-2F3`. + +Erwartung: + +- Wizard zeigt den Wert; +- Nutzer kann Quellwert behalten, Zuordnung übernehmen oder ignorieren; +- keine Option wird automatisch gewählt; +- unpassende Verteilung oder fehlender Circuit erzeugt Konflikt beziehungsweise bewusste Anlageentscheidung. + +### Szenario G – CSV-Passthrough + +- Titelzeile; +- Kopfzeile in Zeile 2; +- Leerzeile; +- Objektzeilen; +- Summenzeile ohne `IfcGUID`. + +Erwartung: + +- nur Objektzeilen erzeugen externe Objekte; +- Summenzeile erzeugt keine Fehlermeldung; +- Rückexport enthält alle Zeilen an derselben Position. + +## 25. Noch offene, phasengebundene Entscheidungen + +Diese Punkte blockieren die Erstellung dieser Spezifikation nicht. + +### Vor Phase 14.2 zu entscheiden + +- konkrete Benennung der neuen Domainobjekte und Tabellen; +- ob Importentwürfe vor der endgültigen Übernahme serverseitig gespeichert werden; +- genaue Versionierung der Mappingkonfiguration; +- genaue Speicherung der Original-CSV: vollständige Rohdatei, logische Zellmatrix oder Kombination. + +### Vor Phase 14.3 zu entscheiden + +- visuelle Position des Objekt-Drawers; +- Verhalten einer Mehrfachauswahl mit widersprüchlichen Planungswerten; +- Warnungsdialog bei fachlich unpassender Row; +- Verhalten, wenn eine Row manuelle Mengenanteile und externe Objekte kombiniert; +- genaue UI für das Lösen einer einzelnen externen Objektverknüpfung aus einer aggregierten Row. + +### Vor Phase 14.4 zu entscheiden + +- exaktes Revit-Zahlenformat für Dezimalwerte; +- genaue Formatierung von Wattwerten, zum Beispiel `1000 W` oder `1000`; +- Umgang mit Objekten ohne Stromkreis beim Rückexport: leeres Feld oder unveränderter Quellwert; +- Dateinamenschema der Rückgabe. + +### Spätere Ausbaustufen + +- automatische raumbezogene Stromkreisvorschläge; +- projektübergreifend wiederverwendbare Mappingprofile; +- Teilmodelle und mehrere externe Quellen; +- Dynamo/API/JSON-Transportadapter; +- Rollen und Berechtigungen; +- Hintergrundjobs; +- PostgreSQL. + +## 26. Arbeitsweise für Codex + +Vor jeder Phase: + +1. relevante bestehende Module auditieren; +2. angeben, was erhalten, erweitert oder ersetzt wird; +3. betroffene Commands, Snapshots und Transferformate nennen; +4. Migration und Rückwärtsverträglichkeit erläutern; +5. fokussierte Tests festlegen. + +Während der Implementierung: + +- kleine reviewbare Arbeitspakete; +- keine fachlichen Writes außerhalb der Command-Grenze; +- keine automatische BMK-Änderung; +- keine stillen Revit- oder ProjectDevice-Synchronisationen; +- keine ungefragte PostgreSQL- oder Dynamo-Implementierung; +- keine Kopplung von Domainlogik an CSV, React oder SQLite. + +Nach jedem Arbeitspaket: + +- fokussierte Tests; +- `npm test`; +- `npm run build:api`; +- `npm run build:web`; +- `npm run typecheck:scripts`; +- `npx tsc --noEmit -p tsconfig.next.json`; +- Dokumentation des neuen unterstützten Laufzeitpfads aktualisieren. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md new file mode 100644 index 0000000..be50eb1 --- /dev/null +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -0,0 +1,402 @@ +# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung + +## Status + +Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den +gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen. +Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem +additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert +noch nicht. + +Fachliche Quelle bleibt +[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). +Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die +Reihenfolge kleiner Arbeitspakete. + +## Ergebnis des Bestandsaudits + +### Beibehalten + +- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis + viele `CircuitDeviceRow`-Datensätze. +- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf + diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht + ausdrücklich bestätigt. +- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung + zwischen einer Gerätezeile und genau einer Projektgerätevorlage. +- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte, + Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen + vierten, fachlich eigenen Pfad. +- Alle bestätigten Änderungen verwenden typisierte Commands mit + `expectedRevision`, vollständiger Gegenrichtung und + `project-command-transaction.persistence.ts` als gemeinsamer + Transaktionsgrenze. +- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit + fester Fremdschlüsselreihenfolge und UUID-Remapping. +- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte + Laufzeitadapter. + +### Erweitern oder gezielt refaktorieren + +- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den + bestätigten externen Projektzustand erweitert. Bereits veröffentlichte + Snapshot-Versionen werden nicht verändert. +- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration, + Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine + konkreten SQLite-Abhängigkeiten. +- Der Stromkreislisteneditor erhält später einen separaten Drawer für + unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht + dafür zweckentfremdet. +- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer + `CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“. + +### Hinzufügen + +- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain; +- projektweite, versionierte Revit-CSV-Konfiguration; +- eine zunächst implizite externe Modellquelle je Projekt; +- Importbatches einschließlich Originaltransport und akzeptiertem + Konfigurationsstand; +- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und + Planungswerten und einzelnem Lebenszyklus; +- Quellraum-Mappings auf interne Räume und Standardverteilungen; +- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler + abweichender Verteilung; +- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices; +- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und + End-to-End-Tests. + +### Entfernen + +Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14 +kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt. + +## Vorgeschlagenes Domänenmodell + +Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die +jeweilige englische Pluralform in `snake_case`. + +### `ExternalModelSource` + +- stabile interne UUID; +- `projectId`; +- technischer Name und Quelltyp `revit_csv`; +- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie + abgeleitet und nicht als redundanter Zeiger gespeichert. + +In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch +angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die +Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an +`(sourceId, ifcGuid)`. + +### `ExternalCsvConfiguration` + +- stabile UUID und `projectId` mit genau einer aktiven Konfiguration; +- Konfigurationsschema-Version; +- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen, + Dezimalformat und Kopfzeilenerkennung; +- exakte Spaltenzuordnung; +- geordnete, exakte Familie-und-Typ-Regeln; +- monotoner Konfigurationsstand. + +Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in +einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei +verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar, +ohne eine zweite veränderbare Historientabelle einzuführen. + +### `ExternalImportBatch` + +- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel; +- Dateiname, SHA-256 und zugehörige Projektrevision; +- verwendete monotone Konfigurationsversion und vollständiger + Konfigurationssnapshot; +- Originaldatei als Bytes; +- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten. + +Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes +belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten +Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten, +Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren. + +### `ExternalRoomMapping` + +- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel; +- Quellgeschoss-, Raumnummer- und Raumname-Snapshot; +- optionales `roomId`; +- optionale `defaultDistributionBoardId`. + +Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält +das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung. + +### `ExternalModelObject` + +- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`; +- letzter gesehener und letzter akzeptierter Importbatch; +- akzeptierte Quellwerte getrennt als strukturierter Snapshot; +- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame + Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe + und Bemerkung; +- explizite Override-Metadaten; +- optionales `externalRoomMappingId`; +- optionale abweichende `distributionBoardId`; +- optionales `linkedProjectDeviceId`; +- optionales `circuitDeviceRowId`; +- Anwesenheitsstatus für bestätigte fehlende Objekte. + +Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele +einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet +sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre +wirksame Menge größer eins. + +Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem +Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter +Anwesenheits- oder Planungszustand wird persistiert. + +## Mengenmodell der Gerätezeile + +Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller +Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines +Objekts manuelle Mengen löschen oder doppelt zählen. + +Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt: + +```text +quantity = manualQuantity + + Summe(effectiveQuantity aller verknüpften ExternalModelObjects) +``` + +- Bei der Migration erhält jede bestehende Row + `manualQuantity = quantity`; ihr Ergebnis bleibt unverändert. +- Eine ausschließlich aus externen Objekten erstellte Row startet mit + `manualQuantity = 0`. +- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende + Berechnungen, Filter und Exporte und wird im selben Command aktualisiert. +- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet + sein. +- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte + übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht + automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine + ausdrückliche Nutzerentscheidung. +- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim + Lösen bleibt der manuelle Anteil erhalten. + +Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere +Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller +Row-Lebenszyklus ist ausgeschlossen. + +## Command-Schnittstellen + +Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte +Snapshots und werden mit der aktuellen Projektrevision geplant. Große +Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch +bleiben und dürfen spätere Quellstände nicht erneut auswerten. + +- `external-csv-configuration.update`: vollständige projektweite + Konfiguration ändern. +- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe + Objekte und bestätigte Links atomar einfügen. +- `external-import.apply-follow-up`: neuen Batch und die vollständigen + erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte + atomar übernehmen. +- `external-model-object.update-planning`: lokale Werte und Overrides eines + oder mehrerer Objekte ändern. +- `external-model-object.update-project-device-link`: ProjectDevice-Links + explizit setzen oder lösen, ohne das ProjectDevice zu verändern. +- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer + bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen + und alle betroffenen materialisierten Mengen atomar aktualisieren. +- `external-object.assign-to-new-row`: stabile neue Row einfügen, + Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren. +- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz, + Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im + Command gespeichert und bei Redo nicht neu berechnet. +- `external-model-object.delete-missing`: nur ausdrücklich bestätigte, + weiterhin unveränderte fehlende Objekte löschen. + +Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen +`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare +Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und +Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern +keine Projektrevision und laufen über einen eigenen Audit-Store. + +## Snapshot-, Transfer- und Migrationsfolgen + +- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das + Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2 + ergänzt `externalCsvConfiguration = null`. +- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und + Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt + leere externe Collections. +- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende + Werte werden einmalig aus `quantity` übernommen. +- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte + Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge. +- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links + remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte + bleiben unverändert. +- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene + Project-State-Schema-Version wird additiv erhöht. +- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert. + +## Implementierungsfolge + +### 14.1 – CSV-Konfiguration und reine Vorschau + +1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine + synthetische Fixtures ergänzen. **Erledigt.** +2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM, + CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie + Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für + synthetische Testdaten; die Referenzdatei steht noch aus.** +3. Konfiguration über Migration, Snapshot v3, Transfer und den Command + `external-csv-configuration.update` persistieren. **Erledigt.** +4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:** + Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das + deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei + Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash + gegen die Vorschau geprüft. + +**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme +erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2. + +Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen +Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit +`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere +Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die +Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in +einen Commit aufgenommen. + +### 14.2 – Persistente Objekte und Erstimport + +1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen. + **Erledigt:** Migration `0003`, vollständiger externer Read-State sowie + exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt + weiterhin keinen Import-Write außerhalb eines Commands. +2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen. + **Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft; + Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes, + Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs, + lassen IFCGUID und Quelltransport jedoch unverändert. +3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. + **Erledigt:** `external-import.apply-initial` wechselt ausschließlich + zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft + Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige + IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links. + Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt + und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt + die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` + bleibt unverändert. +4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard + ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:** + Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit + exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge, + Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und + ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und + schreibt weder Entwurf noch Projektzustand. Der dedizierte + Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die + Planung, vollständige Entscheidungen für alle Quellräume und exakten + Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht + klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale + ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben + ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen, + bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und + verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende + Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten + Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese + Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen. + +Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung +statt serverseitiger Entwürfe, die Konfigurationsversionierung und die +Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. + +### 14.3 – Editor und explizite Zuordnung + +1. `manualQuantity` und Mengeninvarianten migrieren und testen. + **Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile + `manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen + beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4 + verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert + als manuellen Anteil plus Summe der unteilbaren externen Objektmengen. +2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen. + **Teilweise erledigt:** `external-object.update-row-assignment` deckt + Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und + Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum, + Selektionskennzeichen und Planungswerte, verlangt für bestätigbare + Planungsabweichungen eine explizite Objektliste und aktualisiert Links und + Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist + dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen + Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die + Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit. + Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer + Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig + unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist + als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt + den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK, + Standardschutz und genau einer externen Row wird gemeinsam mit den + Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen + Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt. + BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente + Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen. +3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. + **Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene + GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers, + unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker, + Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden; + Drawer, Filter und Mengenwirkung folgen. +4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload + testen. + +Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl, +Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt. + +### 14.4 – Strukturtreuer Rückexport + +1. Exportprojektion gegen eine bekannte Projektrevision ergänzen. +2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen. +3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten. +4. Download und append-only Exportnachweis ergänzen. + +Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich +festgelegt. + +### 14.5 – Folgeimport und Konflikte + +1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen. +2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren. +3. Einzel- und Sammelentscheidungen im Wizard ergänzen. +4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo + übernehmen. + +### 14.6 – Härtung und End-to-End-Abnahme + +1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren. +2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport, + Snapshot/Restore und Projektduplikat prüfen. +3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen. +4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich + ausgelieferten Stand nachziehen. + +## Risiken und Schutzmaßnahmen + +- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile; + Commands ändern Links und materialisierte Menge gemeinsam. +- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind + getrennt; jede Übernahme benötigt eine Nutzerentscheidung. +- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage; + deren bestehende Commands bleiben alleinige Schreibgrenze. +- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix, + Dialekt, Konfiguration, Hash und Projektrevision. +- **Sehr große Undo-Payloads:** Importcommands speichern vollständige + erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen, + nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert. +- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im + Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit. +- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die + bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu + klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist. + +## Freigabepunkt + +Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine +Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket +ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus. 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 4216834..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,19 +13,21 @@ "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", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "start": "node dist/server/index.js", - "test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "node scripts/run-tests.mjs", + "test:watch": "node scripts/run-tests.mjs --watch", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", - "db:backfill:sections": "tsx scripts/db-backfill-sections.ts", - "db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts" + "revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts" }, "keywords": [], "author": "", @@ -41,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/scripts/db-backfill-sections.ts b/scripts/db-backfill-sections.ts deleted file mode 100644 index 08deb06..0000000 --- a/scripts/db-backfill-sections.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { db } from "../src/db/client.js"; -import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; -import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js"; -import { ProjectRepository } from "../src/db/repositories/project.repository.js"; - -const projectRepository = new ProjectRepository(db); -const circuitListRepository = new CircuitListRepository(db); -const circuitSectionRepository = new CircuitSectionRepository(db); - -async function run() { - const projects = await projectRepository.list(); - let totalLists = 0; - for (const project of projects) { - const lists = await circuitListRepository.listByProject(project.id); - for (const list of lists) { - await circuitSectionRepository.createDefaults(list.id); - totalLists += 1; - } - } - console.log(`Section backfill done for ${totalLists} circuit list(s).`); -} - -run().catch((error) => { - console.error("Section backfill failed:", error); - process.exit(1); -}); diff --git a/scripts/db-migrate-legacy-consumers.ts b/scripts/db-migrate-legacy-consumers.ts deleted file mode 100644 index 7da8d70..0000000 --- a/scripts/db-migrate-legacy-consumers.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { db } from "../src/db/client.js"; -import { CircuitRepository } from "../src/db/repositories/circuit.repository.js"; -import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; -import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js"; -import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js"; -import { ProjectRepository } from "../src/db/repositories/project.repository.js"; -import { RoomRepository } from "../src/db/repositories/room.repository.js"; -import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js"; - -const projectRepository = new ProjectRepository(db); -const circuitListRepository = new CircuitListRepository(db); -const migrationRepository = new LegacyConsumerMigrationRepository(db); -const migrationService = new LegacyConsumerMigrationService({ - circuitListReader: circuitListRepository, - sectionStore: new CircuitSectionRepository(db), - circuitReader: new CircuitRepository(db), - roomReader: new RoomRepository(db), - migrationStore: migrationRepository, -}); - -async function run() { - const projects = await projectRepository.list(); - const reports = []; - - for (const project of projects) { - const lists = await circuitListRepository.listByProject(project.id); - for (const list of lists) { - const report = await migrationService.migrateCircuitList(project.id, list.id); - reports.push({ - projectId: project.id, - circuitListId: list.id, - legacyConsumerCount: report.legacyConsumerCount, - createdCircuitCount: report.createdCircuitCount, - createdDeviceRowCount: report.createdDeviceRowCount, - generatedIdentifiers: report.generatedIdentifiers.length, - unassignedRows: report.unassignedRows.length, - }); - } - } - - const unmigratedConsumers = - await migrationRepository.listUnmigratedConsumers(); - if (unmigratedConsumers.length > 0) { - const withoutCircuitList = unmigratedConsumers.filter( - (consumer) => !consumer.circuitListId - ).length; - throw new Error( - `Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` + - ` (${withoutCircuitList} without a circuit list).` - ); - } - - console.log("Legacy consumer migration summary:"); - console.table(reports); - console.log("Cutover verification passed: all legacy consumers are mapped."); -} - -run().catch((error) => { - console.error("Legacy consumer migration failed:", error); - process.exit(1); -}); diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 0fff995..8680f59 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -12,8 +12,6 @@ const requiredTables = [ "circuit_protection_devices", "distribution_board_components", "distribution_board_component_protection_devices", - "legacy_consumer_circuit_migrations", - "legacy_consumer_migration_reports", ]; const tablePlaceholders = requiredTables.map(() => "?").join(", "); @@ -42,6 +40,13 @@ const projectDeviceColumns = new Set( const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter( (name) => !projectDeviceColumns.has(name) ); +const requiredCircuitDeviceRowColumns = ["manual_quantity"]; +const circuitDeviceRowColumns = new Set( + db.prepare("PRAGMA table_info(circuit_device_rows)").all().map((column) => column.name) +); +const missingCircuitDeviceRowColumns = requiredCircuitDeviceRowColumns.filter( + (name) => !circuitDeviceRowColumns.has(name) +); const removedProjectDeviceColumns = [ "installed_power_per_unit_kw", "demand_factor", @@ -57,6 +62,14 @@ const circuitColumns = new Set( db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name) ); const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name)); +const removedCircuitColumns = [ + "protection_type", + "protection_rated_current", + "protection_characteristic", +]; +const remainingRemovedCircuitColumns = removedCircuitColumns.filter((name) => + circuitColumns.has(name) +); const requiredCircuitSectionColumns = ["category", "group_number"]; const circuitSectionColumns = new Set( db @@ -72,6 +85,7 @@ const requiredProjectColumns = [ "external_project_number", "building_owner", "description", + "is_public_building", "enabled_distribution_board_supply_types", ]; const projectColumns = new Set( @@ -124,6 +138,7 @@ console.log("Database:", dbPath); console.log("Required tables:", requiredTables.join(", ")); console.log("Existing tables:", [...existing].join(", ") || "(none)"); console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", ")); +console.log("Required circuit-device-row columns:", requiredCircuitDeviceRowColumns.join(", ")); console.log("Required circuit columns:", requiredCircuitColumns.join(", ")); console.log( "Required circuit-section columns:", @@ -164,6 +179,22 @@ if (missingCircuitColumns.length > 0) { process.exit(1); } +if (missingCircuitDeviceRowColumns.length > 0) { + console.error( + "Missing circuit-device-row columns:", + missingCircuitDeviceRowColumns.join(", ") + ); + process.exit(1); +} + +if (remainingRemovedCircuitColumns.length > 0) { + console.error( + "Transitional circuit protection columns still present:", + remainingRemovedCircuitColumns.join(", ") + ); + process.exit(1); +} + if (missingCircuitSectionColumns.length > 0) { console.error( "Missing circuit-section columns:", diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh new file mode 100644 index 0000000..bf0d856 --- /dev/null +++ b/scripts/docker-start.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +echo "Running migrations..." +node scripts/run-migrations.js + +echo "Starting API server on :3000..." +node dist/server/index.js & + +echo "Waiting for API..." +until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do + sleep 1 +done + +echo "Starting Next.js on :3001..." +exec node_modules/.bin/next start -p 3001 diff --git a/scripts/run-migrations.js b/scripts/run-migrations.js new file mode 100644 index 0000000..ebbed8a --- /dev/null +++ b/scripts/run-migrations.js @@ -0,0 +1,16 @@ +const { migrate } = require('drizzle-orm/better-sqlite3/migrator'); +const { drizzle } = require('drizzle-orm/better-sqlite3'); +const Database = require('better-sqlite3'); +const fs = require('node:fs'); +const path = require('node:path'); + +const dataDir = path.resolve('data'); +if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + +const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db')); +const db = drizzle(sqlite); + +migrate(db, { migrationsFolder: path.resolve('src/db/migrations') }); +sqlite.close(); + +console.log('Migrations applied'); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..ab48b36 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,30 @@ +import { readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const testFiles = readdirSync(path.resolve("tests"), { + withFileTypes: true, +}) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")) + .map((entry) => path.join("tests", entry.name)) + .sort(); + +if (testFiles.length === 0) { + throw new Error("No TypeScript test files found."); +} + +const watch = process.argv.includes("--watch"); +const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "--test", + "--test-concurrency=1", + ...(watch ? ["--watch"] : []), + ...testFiles, + ], + { stdio: "inherit" } +); + +process.exit(result.status ?? 1); diff --git a/scripts/verify-revit-reference-csv.ts b/scripts/verify-revit-reference-csv.ts new file mode 100644 index 0000000..07d605b --- /dev/null +++ b/scripts/verify-revit-reference-csv.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; +import { + parseExternalCsv, + serializeExternalCsv, +} from "../src/external-model/csv/external-csv-transport.js"; +import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; + +const filePath = resolve( + process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv" +); +const source = readFileSync(filePath); +const configuration = createDefaultExternalCsvConfiguration({ + ifcGuid: "IfcGUID", + roomNumber: "MEP-Raum: Nummer", + roomName: "MEP-Raum: Name", + familyAndType: "Familie und Typ", + selectionMarker: "CAx_Auswahlkenner", + circuitIdentifier: "kbp_Stromkreisnummer", + power: "kbp-E-Elektrische Leistung", + quantity: null, +}); +configuration.additionalSourceMappings = [ + { sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" }, + { sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" }, + { sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" }, + { sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" }, + { sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" }, +]; + +const document = parseExternalCsv(source, configuration); +const counts = Object.fromEntries( + ["metadata", "header", "passthrough", "object", "suspect-object"].map( + (classification) => [ + classification, + document.rows.filter((row) => row.classification === classification).length, + ] + ) +); +const nonEmptyPassthroughRows = document.rows.filter( + (row) => + row.classification === "passthrough" && + row.cells.some((cell) => cell.value !== "") +).length; +const serialized = serializeExternalCsv(document); +const preview = createExternalCsvPreview({ + fileName: filePath, + bytes: source, + configuration, +}); + +assert.equal(counts.object, 897, "Expected 897 object rows."); +assert.equal(preview.objectCount, 897, "Expected 897 preview objects."); +assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows."); +assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows."); +assert.equal( + Buffer.compare(Buffer.from(serialized), source), + 0, + "Expected a byte-identical CSV round-trip." +); + +const sha256 = createHash("sha256").update(source).digest("hex"); +process.stdout.write( + `${JSON.stringify( + { + file: filePath, + bytes: source.length, + sha256, + dialect: document.dialect, + rows: counts, + nonEmptyPassthroughRows, + roundTripByteIdentical: true, + previewObjectCount: preview.objectCount, + }, + null, + 2 + )}\n` +); diff --git a/src/app/globals.css b/src/app/globals.css index 351a429..22da5df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,9 +1,453 @@ +/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md), + projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite). + 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. + + 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 */ + --color-primary-deep: #0a1920; /* Petrol Deep */ + --color-accent: #4e7e93; /* Petrol Light */ + --color-accent-pale: #9dbac9; /* Petrol Pale */ + --color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */ + --color-signal-dark: #237a47; + --color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */ + --color-attention-dark: #9c5a26; + --color-ink: #182229; /* Graphite */ + --color-ink-soft: #59666d; /* Slate */ + --color-bg: #ffffff; + --color-surface: #f1f2ee; /* Fog */ + --color-border: #dee2dc; + --color-danger: #b04035; + --color-danger-bg: #f7e6e3; + --color-warn: #a9791e; + + --font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif; + --font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif; + + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --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 { + scroll-behavior: smooth; +} + body { - background-color: #f5f7fb; + background-color: var(--color-surface); + color: var(--color-ink); +} + +a { + color: var(--color-primary); +} + +/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */ + +.btn-primary { + --bs-btn-bg: var(--color-primary); + --bs-btn-border-color: var(--color-primary); + --bs-btn-hover-bg: var(--color-primary-dark); + --bs-btn-hover-border-color: var(--color-primary-dark); + --bs-btn-active-bg: var(--color-primary-deep); + --bs-btn-active-border-color: var(--color-primary-deep); + --bs-btn-disabled-bg: var(--color-primary); + --bs-btn-disabled-border-color: var(--color-primary); +} + +.btn-outline-primary { + --bs-btn-color: var(--color-primary); + --bs-btn-border-color: var(--color-primary); + --bs-btn-hover-bg: var(--color-primary); + --bs-btn-hover-border-color: var(--color-primary); + --bs-btn-active-bg: var(--color-primary-dark); + --bs-btn-active-border-color: var(--color-primary-dark); +} + +.btn-success, +.btn-outline-success { + --bs-btn-bg: var(--color-signal); + --bs-btn-border-color: var(--color-signal); + --bs-btn-color: #fff; + --bs-btn-hover-bg: var(--color-signal-dark); + --bs-btn-hover-border-color: var(--color-signal-dark); + --bs-btn-active-bg: var(--color-signal-dark); + --bs-btn-active-border-color: var(--color-signal-dark); +} + +.btn-outline-success { + --bs-btn-bg: transparent; + --bs-btn-color: var(--color-signal-dark); + --bs-btn-hover-color: #fff; +} + +.form-control:focus, +.form-select:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15); +} + +.form-check-input:checked { + background-color: var(--color-primary); + border-color: var(--color-primary); +} + +.text-bg-secondary { + background-color: var(--color-surface) !important; + color: var(--color-ink-soft) !important; +} + +.alert-success { + --bs-alert-color: var(--color-signal-dark); + --bs-alert-bg: #eaf6f0; + --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 { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 100vh; +} + +.sidebar { + background: linear-gradient( + 160deg, + var(--color-primary-deep) 0%, + var(--color-primary-dark) 45%, + var(--color-primary) 100% + ); + color: #fff; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar-brand { + padding: 22px 22px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.sidebar-wordmark { + font-size: 1.2rem; + font-weight: 700; + color: #fff; +} + +.sidebar-bar { + height: 3px; + background: linear-gradient(90deg, var(--color-signal), var(--color-attention)); + margin-top: 9px; + width: 42px; + border-radius: 2px; +} + +.sidebar-tag { + font-size: 0.72rem; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--color-accent-pale); + margin-top: 10px; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px; + flex: 1; +} + +.sidebar-section-label { + font-size: 0.66rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(199, 214, 224, 0.7); + padding: 16px 14px 4px; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + color: #c7d6e0; + text-decoration: none; + font-size: 0.92rem; + font-weight: 500; + border: 0; + background: transparent; + width: 100%; + text-align: left; + cursor: pointer; +} + +.sidebar-link:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + text-decoration: none; +} + +.sidebar-link.active { + background: rgba(255, 255, 255, 0.14); + color: #fff; + box-shadow: inset 3px 0 0 var(--color-signal); +} + +.sidebar-icon { + width: 1.1em; + display: inline-flex; + 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; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-lg); + flex-wrap: wrap; +} + +.page-header h1 { + font-size: 1.5rem; + font-weight: 700; + 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; + text-transform: uppercase; + color: var(--color-accent); + margin-bottom: 2px; + font-weight: 600; +} + +/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */ + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.kpi { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 9px; + padding: 13px 15px; + display: block; + color: inherit; + text-decoration: none; + transition: box-shadow 0.15s, transform 0.15s; +} + +a.kpi:hover { + color: inherit; + text-decoration: none; + box-shadow: 0 3px 8px var(--shadow-soft), 0 16px 32px -14px var(--shadow-strong); + transform: translateY(-1px); +} + +.kpi-label { + font-size: 0.66rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-ink-soft); + margin-bottom: var(--space-xs); + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.kpi-value { + font-size: 1.2rem; + font-weight: 700; + line-height: 1.1; + color: var(--color-primary); +} + +:root[data-bs-theme="dark"] .kpi-value { + color: var(--color-accent-pale); } .card { - border-radius: 0.5rem; + border-radius: var(--radius); + border-top: 3px solid var(--color-primary); + box-shadow: + 0 1px 2px var(--shadow-soft), + 0 10px 24px -14px var(--shadow-strong); +} + +.card-header { + font-weight: 700; + color: var(--color-primary); + background: var(--color-bg); + 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, @@ -11,35 +455,6 @@ body { min-width: 8rem; } -.circuit-tree-table .section-row td { - background: #e9eef8; - border-top: 2px solid #c6d3ea; - font-weight: 600; -} - -.circuit-tree-table .summary-row td { - background: #f5f8fd; - font-weight: 600; -} - -.circuit-tree-table .device-row td { - background: #ffffff; -} - -.circuit-tree-table .reserve-row td { - background: #f8fbff; -} - -.circuit-tree-table .placeholder-row td { - background: #f7f7f7; - color: #6c757d; - font-style: italic; -} - -.circuit-tree-table .indented-cell { - padding-left: 1.5rem; -} - .tree-editor-shell { display: flex; flex-direction: column; @@ -55,13 +470,14 @@ body { 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; @@ -72,8 +488,8 @@ body { } .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; } @@ -84,10 +500,10 @@ body { 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; } @@ -97,10 +513,10 @@ body { .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; @@ -108,7 +524,7 @@ body { .active-view-chip:hover, .active-view-reset:hover { - border-color: #2563eb; + border-color: var(--accent-blue); } .active-view-reset { @@ -121,9 +537,9 @@ body { 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 { @@ -133,13 +549,13 @@ body { } .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; } @@ -153,13 +569,13 @@ body { 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; } @@ -179,7 +595,7 @@ body { .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; @@ -188,7 +604,7 @@ body { .column-settings-explanation { margin-bottom: 0.4rem; - color: #4b5563; + color: var(--text-muted); font-size: 0.72rem; line-height: 1.3; } @@ -196,9 +612,11 @@ body { .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; } @@ -208,7 +626,7 @@ body { gap: 0.15rem; max-height: 300px; overflow: auto; - border: 1px solid #e1e6ef; + border: 1px solid var(--panel-border); border-radius: 4px; padding: 0.25rem; } @@ -224,8 +642,8 @@ body { } .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 { @@ -233,12 +651,12 @@ body { } .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 { @@ -265,7 +683,7 @@ body { flex: 0 0 1rem; align-items: center; justify-content: center; - color: #1d4ed8; + color: var(--accent-blue-marker); font-weight: 700; } @@ -275,8 +693,9 @@ body { } .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; @@ -284,8 +703,8 @@ body { .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; @@ -295,7 +714,7 @@ body { .column-settings-empty { padding: 0.55rem 0.35rem; - color: #6b7280; + color: var(--text-faint); font-size: 0.76rem; } @@ -305,8 +724,8 @@ body { } .column-settings-footer button.primary { - border-color: #2563eb; - background: #2563eb; + border-color: var(--accent-blue); + background: var(--accent-blue); color: #fff; } @@ -318,8 +737,8 @@ body { } .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; @@ -329,14 +748,17 @@ body { .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 { @@ -348,8 +770,9 @@ body { } .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; @@ -360,8 +783,8 @@ body { } .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 { @@ -380,11 +803,13 @@ body { 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; @@ -395,18 +820,19 @@ body { 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; @@ -429,8 +855,9 @@ body { .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; @@ -447,7 +874,7 @@ body { border: 0; background: transparent; padding: 0; - color: #1f2937; + color: var(--text-strong); font: inherit; font-size: 0.78rem; font-weight: 600; @@ -468,7 +895,7 @@ body { 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 { @@ -479,13 +906,14 @@ body { } .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; @@ -497,23 +925,24 @@ body { } .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; @@ -537,7 +966,7 @@ body { .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; @@ -552,13 +981,14 @@ body { .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; @@ -567,7 +997,7 @@ body { .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; @@ -575,9 +1005,11 @@ body { .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; } @@ -587,7 +1019,7 @@ body { gap: 0.15rem; max-height: 210px; overflow: auto; - border: 1px solid #e1e6ef; + border: 1px solid var(--panel-border); border-radius: 4px; padding: 0.25rem; } @@ -601,7 +1033,7 @@ body { 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; @@ -609,17 +1041,17 @@ body { } .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; } @@ -632,8 +1064,8 @@ body { } .tree-grid .header-filter-footer button.primary { - border-color: #2563eb; - background: #2563eb; + border-color: var(--accent-blue); + background: var(--accent-blue); color: #fff; } @@ -643,7 +1075,7 @@ body { } .tree-grid .header-filter-warning { - color: #9a3412; + color: var(--grid-warn-strong); font-size: 0.7rem; white-space: normal; } @@ -653,10 +1085,61 @@ body { } .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: var(--panel-border); +} + +.tree-grid .structure-component-row.headerComponent td { + background: var(--surface-component-header); +} + +.tree-grid .structure-component-row.groupComponent td { + background: var(--surface-component-group); +} + +.tree-grid .structure-component-row.footerComponent td { + background: var(--surface-component-footer); +} + +.tree-grid .structure-component-content { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.tree-grid .structure-component-content strong { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + +.tree-grid .structure-component-protection { + color: var(--text-subtle); + font-size: 0.78rem; +} + +.tree-grid .structure-component-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; +} + +.tree-grid .structure-component-actions button { + font-size: 0.75rem; + padding: 0.18rem 0.45rem; +} + +.tree-grid .structure-component-fixed { + color: var(--text-faint); + font-size: 0.75rem; +} + .tree-grid .section-drop-cell { padding: 0.45rem 0.6rem; } @@ -674,32 +1157,33 @@ body { } .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; } @@ -707,6 +1191,24 @@ body { cursor: text; } +.tree-grid .cell-protection-trigger { + color: var(--color-primary); + cursor: pointer; + font-weight: 600; + transition: + background-color 0.12s ease, + box-shadow 0.12s ease; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-protection-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-protection-trigger:hover { + background: rgba(63, 166, 107, 0.15); + box-shadow: inset 0 0 0 1px var(--color-signal); +} + .tree-grid .device-drag-handle { cursor: grab; } @@ -724,7 +1226,7 @@ body { } .tree-grid .cell-selected { - outline: 2px solid #4c7dd9; + outline: 2px solid var(--accent-blue-strong-border); outline-offset: -2px; } @@ -735,28 +1237,30 @@ body { } .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 { @@ -765,26 +1269,27 @@ body { } .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, @@ -798,7 +1303,7 @@ body { left: -1px; right: -1px; top: -2px; - border-top: 4px solid #2563eb; + border-top: 4px solid var(--accent-blue); pointer-events: none; } @@ -811,7 +1316,7 @@ body { 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; } @@ -821,7 +1326,7 @@ body { left: -1px; right: -1px; bottom: -2px; - border-bottom: 4px solid #2563eb; + border-bottom: 4px solid var(--accent-blue); pointer-events: none; } @@ -834,13 +1339,13 @@ body { 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; } @@ -848,21 +1353,22 @@ body { 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 { @@ -873,12 +1379,13 @@ body { } .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; } @@ -889,3 +1396,68 @@ body { max-height: calc(100vh - 5.5rem); } } + +/* ── Seitenleiste: eingeklappter Zustand ─────────────────────────────── */ + +.app-shell { + transition: grid-template-columns 0.18s ease; +} + +.app-shell-collapsed { + grid-template-columns: 64px 1fr; +} + +.sidebar { + transition: padding 0.18s ease; +} + +.sidebar-brand { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.sidebar-toggle { + background: rgba(255, 255, 255, 0.08); + border: 0; + border-radius: 6px; + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.8rem; + line-height: 1; + width: 26px; + height: 26px; +} + +.sidebar-toggle:hover { + background: rgba(255, 255, 255, 0.18); +} + +.sidebar-collapsed .sidebar-brand { + padding: 16px 10px; + flex-direction: column; + align-items: center; +} + +.sidebar-collapsed .sidebar-brand-text { + display: none; +} + +.sidebar-collapsed .sidebar-nav { + padding: 12px 8px; +} + +.sidebar-collapsed .sidebar-link { + justify-content: center; + padding: 10px; +} + +.sidebar-collapsed .sidebar-section-label, +.sidebar-collapsed .sidebar-label { + display: none; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a67de86..cd2407b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,16 +1,43 @@ 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"; export const metadata: Metadata = { title: "Leistungsbilanz", 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} + + + + {children} + ); } diff --git a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx index f661942..89679cf 100644 --- a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx +++ b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx @@ -15,12 +15,6 @@ export default function CircuitTreeEditPage() {

Stromkreise und zugeordnete Geräte bearbeiten.

- - Strukturvorschau - Zum Projekt diff --git a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx deleted file mode 100644 index 8cfea27..0000000 --- a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { CircuitTreePreview } from "../../../../../../frontend/components/circuit-tree-preview"; - -export default function CircuitTreePreviewPage() { - const params = useParams<{ projectId: string; circuitListId: string }>(); - const projectId = params.projectId; - const circuitListId = params.circuitListId; - - return ( -
-
-
-

Vorschau der Stromkreisstruktur

-

- Schreibgeschützte Vorschau der Bereiche, Stromkreise und Gerätezeilen. -

-
- - Editor öffnen - - - Zum Projekt - -
- - -
- ); -} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index b574312..fa342ea 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -5,30 +5,37 @@ import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { copyGlobalDeviceToProject, + copyDistributionBoard, copyProjectDeviceToGlobal, createDistributionBoard, createFloor, createProjectDevice, createRoom, + deleteFloor, deleteProjectDevice, + deleteRoom, + deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, + getProject, getProjectDeviceSyncPreview, listCircuitLists, listDistributionBoards, listFloors, listGlobalDevices, listProjectDevices, - listProjects, listRooms, importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, + updateFloor, updateProjectDevice, updateProjectSettings, + updateRoom, } from "../../../frontend/utils/api"; import type { CircuitListDto, + CreateRoomInput, CreateProjectDeviceInput, DistributionBoardDto, FloorDto, @@ -53,6 +60,8 @@ import { } from "../../../frontend/components/project-settings-modal"; import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; +import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; +import { RoomModal } from "../../../frontend/components/room-modal"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -91,11 +100,12 @@ export default function ProjectDetailPage() { editingBoardSimultaneityFactor, setEditingBoardSimultaneityFactor, ] = useState("1"); + const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [floorName, setFloorName] = useState(""); - const [roomNumber, setRoomNumber] = useState(""); - const [roomName, setRoomName] = useState(""); - const [roomFloorId, setRoomFloorId] = useState(""); + const [editingFloor, setEditingFloor] = useState(null); + const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); + const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); const [structureModal, setStructureModal] = useState< "board" | "floor" | "room" | null >(null); @@ -119,7 +129,7 @@ export default function ProjectDetailPage() { return; } Promise.all([ - listProjects(), + getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), listFloors(projectId), @@ -128,7 +138,7 @@ export default function ProjectDetailPage() { listGlobalDevices(), ]) .then(([ - projects, + currentProject, distributionBoards, loadedCircuitLists, loadedFloors, @@ -136,7 +146,6 @@ export default function ProjectDetailPage() { loadedProjectDevices, loadedGlobalDevices, ]) => { - const currentProject = projects.find((item) => item.id === projectId) ?? null; setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); @@ -236,7 +245,7 @@ export default function ProjectDetailPage() { } } - async function handleCreateFloor(event: FormEvent) { + async function handleSaveFloor(event: FormEvent) { event.preventDefault(); if (!projectId || !project || !floorName.trim()) { return; @@ -244,52 +253,61 @@ export default function ProjectDetailPage() { setIsSaving(true); setError(null); try { - const result = await createFloor( - projectId, - { name: floorName.trim() }, - project.currentRevision + const result = editingFloor + ? await updateFloor( + projectId, + editingFloor.id, + { name: floorName.trim() }, + project.currentRevision + ) + : await createFloor( + projectId, + { name: floorName.trim() }, + project.currentRevision + ); + setFloors((current) => + editingFloor + ? current.map((floor) => + floor.id === result.floor.id ? result.floor : floor + ) + : [...current, result.floor] ); - setFloors((current) => [...current, result.floor]); applyProjectRevision(result.history.currentRevision); setFloorName(""); + setEditingFloor(null); setStructureModal(null); } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); + setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden."); } finally { setIsSaving(false); } } - async function handleCreateRoom(event: FormEvent) { - event.preventDefault(); - if ( - !projectId || - !project || - !roomNumber.trim() || - !roomName.trim() - ) { - return; - } + async function handleSaveRoom(input: CreateRoomInput) { + if (!projectId || !project) return; setIsSaving(true); setError(null); try { - const result = await createRoom( - projectId, - { - floorId: roomFloorId || undefined, - roomNumber: roomNumber.trim(), - roomName: roomName.trim(), - }, - project.currentRevision + const result = editingRoom + ? await updateRoom( + projectId, + editingRoom.id, + input, + project.currentRevision + ) + : await createRoom(projectId, input, project.currentRevision); + setRooms((current) => + editingRoom + ? current.map((room) => + room.id === result.room.id ? result.room : room + ) + : [...current, result.room] ); - setRooms((current) => [...current, result.room]); applyProjectRevision(result.history.currentRevision); - setRoomNumber(""); - setRoomName(""); - setRoomFloorId(""); + setEditingRoom(null); setStructureModal(null); } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden."); + setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden."); } finally { setIsSaving(false); } @@ -405,6 +423,7 @@ export default function ProjectDetailPage() { setEditingBoardSimultaneityFactor( String(board.simultaneityFactor) ); + setEditingBoardCopyName(`${board.name} Kopie`); } function openBoardCreator() { @@ -463,6 +482,163 @@ export default function ProjectDetailPage() { } } + async function handleCreateRevitRoom(input: CreateRoomInput) { + if (!projectId || !project) throw new Error("Projekt ist nicht geladen."); + const result = await createRoom(projectId, input, project.currentRevision); + setRooms((current) => [...current, result.room]); + applyProjectRevision(result.history.currentRevision); + return result; + } + + async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) { + if (!projectId || !project) throw new Error("Projekt ist nicht geladen."); + const result = await createProjectDevice(projectId, input, project.currentRevision); + setProjectDevices((current) => [...current, result.projectDevice]); + applyProjectRevision(result.history.currentRevision); + return result; + } + + function openCreateFloor() { + setEditingFloor(null); + setFloorName(""); + setStructureModal("floor"); + } + + function openEditFloor(floor: FloorDto) { + setEditingFloor(floor); + setFloorName(floor.name); + setStructureModal("floor"); + } + + function openCreateRoom() { + setEditingRoom(null); + setStructureModal("room"); + } + + function openEditRoom(room: RoomDto) { + setEditingRoom(room); + setStructureModal("room"); + } + + async function handleDeleteFloor() { + if (!projectId || !project || !editingFloor) return; + if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return; + setIsSaving(true); + setError(null); + try { + const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision); + setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id)); + applyProjectRevision(result.history.currentRevision); + setEditingFloor(null); + setFloorName(""); + setStructureModal(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteRoom() { + if (!projectId || !project || !editingRoom) return; + if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return; + setIsSaving(true); + setError(null); + try { + const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision); + setRooms((current) => current.filter((room) => room.id !== editingRoom.id)); + applyProjectRevision(result.history.currentRevision); + setEditingRoom(null); + setStructureModal(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCopyBoard() { + if ( + !projectId || + !project || + !editingBoard || + !editingBoardCopyName.trim() + ) { + return; + } + setIsSaving(true); + setError(null); + try { + const result = await copyDistributionBoard( + projectId, + editingBoard.id, + editingBoardCopyName.trim(), + project.currentRevision + ); + setBoards((current) => [ + ...current, + result.distributionBoard, + ]); + setCircuitLists((current) => [ + ...current, + result.circuitList, + ]); + applyProjectRevision(result.history.currentRevision); + setEditingBoard(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht kopiert werden." + ); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteBoard() { + if (!projectId || !project || !editingBoard) { + return; + } + const confirmed = window.confirm( + `Verteilung „${editingBoard.name}“ wirklich löschen?\n\n` + + "Dabei werden die Stromkreisliste, alle Gruppen, Stromkreise, Gerätezeilen, Schutzgeräte und weiteren Verteilergeräte entfernt. " + + "Der Vorgang kann anschließend über die projektweite Historie rückgängig gemacht werden." + ); + if (!confirmed) { + return; + } + setIsSaving(true); + setError(null); + try { + const result = await deleteDistributionBoard( + projectId, + editingBoard.id, + project.currentRevision + ); + setBoards((current) => + current.filter((board) => board.id !== result.distributionBoardId) + ); + setCircuitLists((current) => + current.filter( + (circuitList) => + circuitList.distributionBoardId !== + result.distributionBoardId + ) + ); + applyProjectRevision(result.history.currentRevision); + setEditingBoard(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht gelöscht werden." + ); + } finally { + setIsSaving(false); + } + } + async function handleExportProject() { if (!project) { return; @@ -661,9 +837,10 @@ export default function ProjectDetailPage() { return (
-
+
-

{project?.name ?? "Projekt"}

+
Projekt
+

{project?.name ?? "Projekt"}

{project?.internalProjectNumber ? `Projektnummer ${project.internalProjectNumber} · ` @@ -672,6 +849,14 @@ export default function ProjectDetailPage() {

+
: null} + +
{project ? ( -
+
window.location.reload()} @@ -699,7 +903,7 @@ export default function ProjectDetailPage() {
) : null} -
+
@@ -792,7 +996,7 @@ export default function ProjectDetailPage() {
-
+
@@ -801,7 +1005,7 @@ export default function ProjectDetailPage() {
    {floors.map((floor) => ( -
  • - {floor.name} +
  • + {floor.name} +
  • ))} {!floors.length ? ( @@ -820,7 +1027,7 @@ export default function ProjectDetailPage() {
-
+
@@ -829,7 +1036,7 @@ export default function ProjectDetailPage() {
+ ))} {!rooms.length ? ( - + Noch keine Räume vorhanden. @@ -866,7 +1079,7 @@ export default function ProjectDetailPage() {
-
+
Projektgeräte @@ -1091,7 +1304,7 @@ export default function ProjectDetailPage() { {structureModal === "board" ? ( setStructureModal(null)} onSubmit={handleCreateBoard} @@ -1251,17 +1464,89 @@ export default function ProjectDetailPage() {
+
+
+

+ Verteilung kopieren +

+

+ Erstellt eine vollständige Kopie des zuletzt gespeicherten + Stands einschließlich Gruppen, Stromkreisen, Gerätezeilen und + Schutzgeräten. Noch nicht gespeicherte Änderungen oben werden + nicht übernommen. +

+
+
+ + + setEditingBoardCopyName(event.target.value) + } + value={editingBoardCopyName} + /> +
+
+ +
+
+
+
+
+

+ Gefahrenbereich +

+

+ Entfernt die vollständige Verteilung. Die Aktion wird erst + nach einer ausdrücklichen Warnung ausgeführt und kann über die + projektweite Historie rückgängig gemacht werden. +

+ +
) : null} {structureModal === "floor" ? ( setStructureModal(null)} - onSubmit={handleCreateFloor} + onClose={() => { + setStructureModal(null); + setEditingFloor(null); + }} + onSubmit={handleSaveFloor} submitDisabled={!floorName.trim()} - submitLabel="Etage hinzufügen" - title="Etage hinzufügen" + submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"} + title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"} > ) : null} {structureModal === "room" ? ( - setStructureModal(null)} - onSubmit={handleCreateRoom} - submitDisabled={!roomNumber.trim() || !roomName.trim()} - submitLabel="Raum anlegen" - title="Raum hinzufügen" - > -
-
- - setRoomNumber(event.target.value)} - required - value={roomNumber} - /> -
-
- - setRoomName(event.target.value)} - required - value={roomName} - /> -
-
- - -
-
-
+ onClose={() => { + setStructureModal(null); + setEditingRoom(null); + }} + onDelete={editingRoom ? () => void handleDeleteRoom() : undefined} + onSave={handleSaveRoom} + /> ) : null} {isProjectDeviceModalOpen ? ( @@ -1369,6 +1621,16 @@ export default function ProjectDetailPage() { usedDistributionBoardSupplyTypes={usedBoardSupplyTypes} /> ) : null} + {project && isRevitCsvOpen ? ( + setIsRevitCsvOpen(false)} + onCreateProjectDevice={handleCreateRevitProjectDevice} + onCreateRoom={handleCreateRevitRoom} + onRevisionChange={applyProjectRevision} + projectId={projectId} + /> + ) : null}
); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 14a459d..21409fd 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -8,14 +8,15 @@ import { deleteGlobalDevice, importProjectTransferAsNew, listGlobalDevices, - listProjects, updateGlobalDevice, } from "../../frontend/utils/api"; import type { CreateGlobalDeviceInput, GlobalDeviceDto, - ProjectDto, } from "../../frontend/types"; +import { GzfPickerModal } from "../../frontend/components/gzf-picker-modal"; + +const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed"; const emptyGlobalDevice: CreateGlobalDeviceInput = { name: "", @@ -38,7 +39,6 @@ function toOptionalNumber(value: string) { } export default function ProjectsPage() { - const [projects, setProjects] = useState([]); const [globalDevices, setGlobalDevices] = useState([]); const [projectName, setProjectName] = useState(""); const [globalDeviceForm, setGlobalDeviceForm] = useState>({ @@ -64,12 +64,15 @@ export default function ProjectsPage() { projectId: string; name: string; } | null>(null); + const [createdProject, setCreatedProject] = useState<{ + id: string; + name: string; + } | null>(null); + const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false); async function loadData() { setError(null); - const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]); - setProjects(loadedProjects); - setGlobalDevices(loadedGlobalDevices); + setGlobalDevices(await listGlobalDevices()); } useEffect(() => { @@ -85,10 +88,12 @@ export default function ProjectsPage() { } setIsSaving(true); setError(null); + setCreatedProject(null); try { const created = await createProject(projectName.trim()); - setProjects((current) => [...current, created]); + setCreatedProject({ id: created.id, name: created.name }); setProjectName(""); + window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT)); } catch (err) { setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden."); } finally { @@ -123,11 +128,11 @@ export default function ProjectsPage() { setImportedProject(null); try { const imported = await importProjectTransferAsNew(projectImport); - setProjects(await listProjects()); setImportedProject(imported); setProjectImport(null); setProjectImportFilename(""); setProjectImportInputKey((current) => current + 1); + window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT)); } catch (err) { setError( err instanceof Error @@ -222,130 +227,107 @@ export default function ProjectsPage() { return (
-
+
-

Projekte

+
Leistungsbilanz
+

Projekte

Projektübersicht und globale Geräteverwaltung

{error ?
{error}
: null} -
-
-
-
Neues Projekt
-
-
-
- - setProjectName(event.target.value)} - placeholder="z. B. Neubau Schule" - /> -
- -
-
-
-
-
Projekt importieren
-
-

- Eine exportierte JSON-Projektdatei wird als eigenständige Kopie - mit neuen internen IDs angelegt. -

- - - void handleProjectImportFile(event.target.files?.[0]) - } - type="file" - /> - {projectImportFilename ? ( -
- {projectImportFilename} ist bereit. -
- ) : null} - {projectImportFileError ? ( -
- {projectImportFileError} -
- ) : null} - - {importedProject ? ( -
-
- „{importedProject.name}“ wurde angelegt. -
- - Importiertes Projekt öffnen - + + {createdProject ? ( +
+
+ „{createdProject.name}“ wurde angelegt.
- ) : null} -
+ + Projekt öffnen + +
+ ) : null}
-
-
-
Alle Projekte
-
- - - - - - - - - {projects.map((project) => ( - - - - - ))} - {!projects.length ? ( - - - - ) : null} - -
ProjektAktionen
{project.name} - - Projekt öffnen - -
- Noch keine Projekte vorhanden. -
-
+
+
Projekt importieren
+
+

+ Eine exportierte JSON-Projektdatei wird als eigenständige Kopie + mit neuen internen IDs angelegt. +

+ + + void handleProjectImportFile(event.target.files?.[0]) + } + type="file" + /> + {projectImportFilename ? ( +
+ {projectImportFilename} ist bereit. +
+ ) : null} + {projectImportFileError ? ( +
+ {projectImportFileError} +
+ ) : null} + + {importedProject ? ( +
+
+ „{importedProject.name}“ wurde angelegt. +
+ + Importiertes Projekt öffnen + +
+ ) : null}
-
-
-
Globale Geräte / Verbraucher
+
+
Globale Geräte / Verbraucher
@@ -400,18 +382,28 @@ export default function ProjectsPage() { } />
-
- - setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value })) - } - /> +
+
+ + setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value })) + } + /> + +
+ setCategory(event.target.value as CircuitGroupCategory) + } + value={category} + > + {circuitGroupCategories.map((entry) => ( + + ))} + +
+
+ + setDisplayName(event.target.value)} + placeholder={ + initialSection + ? undefined + : "Leer lassen für den automatisch erzeugten Namen" + } + required={Boolean(initialSection)} + value={displayName} + /> +
+
+ + ); +} diff --git a/src/frontend/components/circuit-protection-modal.tsx b/src/frontend/components/circuit-protection-modal.tsx new file mode 100644 index 0000000..9037711 --- /dev/null +++ b/src/frontend/components/circuit-protection-modal.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { + allowedRatedCurrentsAByProtectionDeviceType, + breakerTripCharacteristics, + fuseProtectionDeviceTypes, + fuseUtilizationCategories, + protectionDeviceTypeLabels, + protectionDeviceTypes, + ratedResidualCurrentsMa, + rcdTypes, + type ProtectionDeviceType, +} from "../../shared/constants/protection-device"; +import type { CircuitTreeProtectionDeviceDto } from "../types"; +import { FormModal } from "./form-modal"; + +interface CircuitProtectionModalProps { + equipmentIdentifier: string; + initialProtection: CircuitTreeProtectionDeviceDto; + isSaving: boolean; + onClose: () => void; + onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise; +} + +export function CircuitProtectionModal({ + equipmentIdentifier, + initialProtection, + isSaving, + onClose, + onSave, +}: CircuitProtectionModalProps) { + const [type, setType] = useState(initialProtection.type); + const [ratedCurrentA, setRatedCurrentA] = useState( + initialProtection.ratedCurrentA + ); + const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState( + initialProtection.fuseUtilizationCategory ?? "gG" + ); + const [tripCharacteristic, setTripCharacteristic] = useState( + initialProtection.tripCharacteristic ?? "B" + ); + const [rcdType, setRcdType] = useState( + initialProtection.rcdType ?? "A" + ); + const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState( + initialProtection.ratedResidualCurrentMa ?? 30 + ); + const usesFuseCategory = ( + fuseProtectionDeviceTypes as readonly string[] + ).includes(type); + const usesTripCharacteristic = + type === "LS" || type === "FI_LS" || type === "AFDD"; + const usesResidualCurrent = type === "FI" || type === "FI_LS"; + const allowedRatedCurrents = + allowedRatedCurrentsAByProtectionDeviceType[type]; + + function handleTypeChange(nextType: ProtectionDeviceType) { + setType(nextType); + setRatedCurrentA( + allowedRatedCurrentsAByProtectionDeviceType[nextType][0] + ); + setFuseUtilizationCategory("gG"); + setTripCharacteristic("B"); + setRcdType("A"); + setRatedResidualCurrentMa(30); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + type, + ratedCurrentA, + ...(usesFuseCategory ? { fuseUtilizationCategory } : {}), + ...(usesTripCharacteristic ? { tripCharacteristic } : {}), + ...(usesResidualCurrent + ? { rcdType, ratedResidualCurrentMa } + : {}), + }); + } + + return ( + +
+
+ + +
+
+ + +
+ {usesFuseCategory ? ( +
+ + +
+ ) : null} + {usesTripCharacteristic ? ( +
+ + +
+ ) : null} + {usesResidualCurrent ? ( + <> +
+ + +
+
+ + +
+ + ) : null} +
+
+ ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 469e4b2..03aa38d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -10,7 +10,9 @@ import { resolveProjectVoltage, } from "../../domain/services/project-voltage.service"; import { + getAdjacentInsertionSortOrder, getInsertionSortOrder, + isGridInsertionRowType, resolveGridInsertionIntent, } from "../utils/circuit-grid-insertion"; import { @@ -25,6 +27,7 @@ import { buildDeviceRowEditPatch, defaultVisibleColumnKeys, deviceFieldKeys, + formatCellValue, formatPhaseTypeLabel, formatValue, getCircuitSectionLabel, @@ -37,6 +40,10 @@ import { buildCircuitDeviceRowInsertSnapshot, buildCircuitInsertSnapshot, } from "../utils/circuit-structure-command"; +import { + getCircuitProtectionEditorInitialValue, + toCircuitProtectionSnapshot, +} from "../utils/circuit-protection-editing"; import { buildCircuitDeviceRowMoveAssignments, } from "../utils/circuit-device-row-move-command"; @@ -46,6 +53,28 @@ import { import { buildCircuitSectionRenumberAssignments, } from "../utils/circuit-section-renumber-command"; +import { + buildCircuitGroupMovePlan, + buildCircuitGroupRenumberPlan, + buildCircuitGroupReorderAssignments, + buildCircuitGroupSubtreeSnapshot, + buildNewCircuitGroupSnapshot, + canMoveCircuitToGroup, + canRenumberCircuitGroups, + canDeleteCircuitGroup, + renameCircuitGroupSnapshot, + summarizeCircuitGroupSubtree, + toCircuitGroupSnapshot, +} from "../utils/circuit-group-editing"; +import { + buildDistributionBoardComponentSnapshot, + getNextComponentSortOrder, + getSuggestedGroupComponentIdentifier, + toDistributionBoardComponentSnapshot, + updateDistributionBoardComponentSnapshot, + type DistributionBoardComponentEditorValues, + type MutableDistributionBoardComponentRole, +} from "../utils/distribution-board-component-editing"; import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; import type { CellKey, @@ -53,7 +82,7 @@ import type { RowType, } from "../utils/circuit-grid-model"; import { - buildVisibleGridRows, + buildVisibleGridRowsWithStructure, filterAndSortCircuitSections, getDistinctFilterValues, } from "../utils/circuit-grid-projection"; @@ -61,24 +90,37 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { deleteCircuitCommand, deleteCircuitDeviceRowCommand, + deleteCircuitGroupCommand, + deleteCircuitGroupSubtreeCommand, + deleteDistributionBoardComponentCommand, getCircuitTree, getNextCircuitIdentifier, getProjectHistory, insertCircuitCommand, insertCircuitDeviceRowCommand, + insertCircuitGroupCommand, + insertDistributionBoardComponentCommand, listProjectDevices, moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsToNewCircuitCommand, + moveCircuitToGroupCommand, reorderCircuitSectionCommand, reorderCircuitSectionsCommand, + reorderCircuitGroupsCommand, + renumberCircuitGroupsCommand, renumberCircuitSectionCommand, redoProjectCommand, updateCircuitById, updateCircuitDeviceRowById, + updateCircuitProtectionCommand, + updateCircuitGroupCommand, + updateDistributionBoardComponentCommand, undoProjectCommand, } from "../utils/api"; import type { CircuitTreeCircuitDto, + CircuitTreeComponentDto, + CircuitTreeProtectionDeviceDto, CircuitTreeResponseDto, CreateCircuitDeviceRowInputDto, CreateCircuitInputDto, @@ -92,6 +134,13 @@ import type { import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; +import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; +import { CircuitGroupModal } from "./circuit-group-modal"; +import { CircuitProtectionModal } from "./circuit-protection-modal"; +import { + circuitGroupCategoryLabels, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group"; type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; @@ -128,16 +177,19 @@ type ProjectDeviceDropIntent = type DeviceRowMoveDropIntent = | { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean } - | { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean }; + | { + kind: "move-to-new-circuit"; + sectionId: string; + requiresConfirmation: boolean; + targetCircuitId?: string; + placement?: "before" | "after"; + }; type CircuitReorderDropIntent = | { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "section-end"; sectionId: string; valid: boolean }; -const LEGACY_COLUMN_LAYOUT_STORAGE_KEY = - "circuitTreeEditor.columnLayout.v1"; - function normalizeUiError(err: unknown): string { const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen."; try { @@ -160,6 +212,22 @@ function normalizeUiError(err: unknown): string { return message; } +type StructureComponentEditorIntent = + | { + kind: "create"; + role: MutableDistributionBoardComponentRole; + sectionId?: string; + initialEquipmentIdentifier?: string; + } + | { + kind: "edit"; + component: CircuitTreeComponentDto; + }; + +type CircuitGroupEditorIntent = + | { kind: "create" } + | { kind: "edit"; section: CircuitTreeResponseDto["sections"][number] }; + function getFullColumnLabel(column: ColumnDef): string { return column.fullLabel ?? column.label; } @@ -200,6 +268,16 @@ 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] = + useState(null); + const [protectionEditorCircuit, setProtectionEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -298,19 +376,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setColumnOrder(allColumns.map((column) => column.key)); setVisibleColumnKeys(defaultVisibleColumnKeys); try { - const parsed = parseStoredColumnLayout( - localStorage.getItem( - getProjectColumnLayoutStorageKey(projectId) - ) ?? - localStorage.getItem( - LEGACY_COLUMN_LAYOUT_STORAGE_KEY - ) + const currentLayout = parseStoredColumnLayout( + localStorage.getItem(getProjectColumnLayoutStorageKey(projectId)) ); - if (!parsed) { + if (!currentLayout) { return; } - setColumnOrder(parsed.order); - setVisibleColumnKeys(parsed.visible); + setColumnOrder(currentLayout.order); + setVisibleColumnKeys(currentLayout.visible); } catch { // ignore invalid local storage and use defaults } finally { @@ -386,8 +459,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // visibleRows is the single normalized grid used by render + navigation + editability. // This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads. const visibleRows = useMemo(() => { - return buildVisibleGridRows(filteredSortedSections); - }, [filteredSortedSections]); + return buildVisibleGridRowsWithStructure(filteredSortedSections, { + headerComponents: data?.headerComponents ?? [], + footerComponents: data?.footerComponents ?? [], + }); + }, [data, filteredSortedSections]); function clearColumnFilter(key: CellKey) { setColumnFilters((current) => { @@ -651,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] @@ -913,6 +1010,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str circuitListId, values: { ...values, voltage }, deviceRows, + category: section.category, }); } @@ -973,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); @@ -983,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await loadTree({ showLoading: false }); setError(message); } finally { + commandInFlightRef.current = false; setIsSaving(false); } } @@ -990,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); @@ -1015,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); } @@ -1027,6 +1135,269 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await applyHistory("undo"); } + async function handleSaveDistributionBoardComponent( + values: DistributionBoardComponentEditorValues + ) { + const intent = componentEditorIntent; + if (!intent || !data) { + return; + } + await runCommand({ + label: + intent.kind === "edit" + ? "Verteilerkomponente bearbeiten" + : "Verteilerkomponente hinzufügen", + redo: async () => { + const result = + intent.kind === "edit" + ? await (() => { + const expected = toDistributionBoardComponentSnapshot( + intent.component + ); + return updateDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + expected, + updateDistributionBoardComponentSnapshot(expected, values) + ); + })() + : await (() => { + const components = + intent.role === "auxiliary" + ? data.footerComponents + : data.sections.find( + (section) => section.id === intent.sectionId + )?.components ?? []; + const snapshot = buildDistributionBoardComponentSnapshot({ + id: crypto.randomUUID(), + circuitListId, + role: intent.role, + sectionId: intent.sectionId, + sortOrder: getNextComponentSortOrder(components), + values, + }); + return insertDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + snapshot + ); + })(); + applyProjectCommandResult(result); + setComponentEditorIntent(null); + return null; + }, + }); + } + + async function handleDeleteDistributionBoardComponent( + component: CircuitTreeComponentDto + ) { + if ( + !confirm( + `Verteilerkomponente „${component.equipmentIdentifier} ${component.name}“ entfernen?` + ) + ) { + return; + } + await runCommand({ + label: "Verteilerkomponente entfernen", + redo: async () => { + const result = await deleteDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + toDistributionBoardComponentSnapshot(component) + ); + applyProjectCommandResult(result); + setComponentEditorIntent(null); + return null; + }, + }); + } + + async function handleSaveCircuitGroup(values: { + category: CircuitGroupCategory; + displayName: string; + }) { + const intent = circuitGroupEditorIntent; + if (!intent || !data) { + return; + } + await runCommand({ + label: + intent.kind === "create" + ? "Stromkreisgruppe hinzufügen" + : "Stromkreisgruppe bearbeiten", + redo: async () => { + const result = + intent.kind === "create" + ? await insertCircuitGroupCommand( + projectId, + getExpectedProjectRevision(), + buildNewCircuitGroupSnapshot({ + id: crypto.randomUUID(), + circuitListId, + category: values.category, + displayName: values.displayName, + sections: data.sections, + }) + ) + : await (() => { + const expected = toCircuitGroupSnapshot( + intent.section, + circuitListId + ); + return updateCircuitGroupCommand( + projectId, + getExpectedProjectRevision(), + expected, + renameCircuitGroupSnapshot(expected, values.displayName) + ); + })(); + applyProjectCommandResult(result); + setCircuitGroupEditorIntent(null); + return null; + }, + }); + } + + async function handleDeleteCircuitGroup( + section: CircuitTreeResponseDto["sections"][number] + ) { + const isEmpty = canDeleteCircuitGroup(section); + const subtree = isEmpty + ? null + : buildCircuitGroupSubtreeSnapshot(section, circuitListId); + const summary = subtree + ? summarizeCircuitGroupSubtree(subtree) + : null; + const warning = isEmpty + ? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?` + : `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` + + `Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` + + "Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden."; + if (!confirm(warning)) { + return; + } + await runCommand({ + label: isEmpty + ? "Leere Stromkreisgruppe entfernen" + : "Befüllte Stromkreisgruppe entfernen", + redo: async () => { + const result = subtree + ? await deleteCircuitGroupSubtreeCommand( + projectId, + getExpectedProjectRevision(), + subtree + ) + : await deleteCircuitGroupCommand( + projectId, + getExpectedProjectRevision(), + toCircuitGroupSnapshot(section, circuitListId) + ); + applyProjectCommandResult(result); + setCircuitGroupEditorIntent(null); + return null; + }, + }); + } + + async function handleMoveCircuitGroup( + groupId: string, + direction: -1 | 1 + ) { + if (!data) { + return; + } + const assignments = buildCircuitGroupReorderAssignments( + data.sections, + groupId, + direction + ); + if (!assignments) { + return; + } + await runCommand({ + label: "Stromkreisgruppen sortieren", + redo: async () => { + const result = await reorderCircuitGroupsCommand( + projectId, + getExpectedProjectRevision(), + circuitListId, + assignments + ); + applyProjectCommandResult(result); + return null; + }, + }); + } + + async function handleRenumberCircuitGroups( + category: CircuitGroupCategory + ) { + if (!data) { + return; + } + let plan: ReturnType; + try { + plan = buildCircuitGroupRenumberPlan(data.sections, category); + } catch (err) { + setError(normalizeUiError(err)); + return; + } + if (!plan) { + return; + } + if ( + !confirm( + `Alle Gruppen der Kategorie „${circuitGroupCategoryLabels[category]}“ gemäß ihrer aktuellen Reihenfolge neu nummerieren? Gruppennummern, Präfixe und zugehörige BMK werden dabei gemeinsam geändert.` + ) + ) { + return; + } + await runCommand({ + label: "Stromkreisgruppen neu nummerieren", + redo: async () => { + const result = await renumberCircuitGroupsCommand( + projectId, + getExpectedProjectRevision(), + circuitListId, + plan + ); + applyProjectCommandResult(result); + return null; + }, + }); + } + + async function handleSaveCircuitProtection( + protection: CircuitTreeProtectionDeviceDto + ) { + const circuit = protectionEditorCircuit; + if (!circuit) { + return; + } + await runCommand({ + label: "Stromkreisschutz bearbeiten", + redo: async () => { + const result = await updateCircuitProtectionCommand( + projectId, + getExpectedProjectRevision(), + circuit.id, + circuit.protectionDevice + ? toCircuitProtectionSnapshot( + circuit.id, + circuit.protectionDevice + ) + : null, + toCircuitProtectionSnapshot(circuit.id, protection) + ); + applyProjectCommandResult(result); + setProtectionEditorCircuit(null); + return null; + }, + }); + } + async function handleRedo() { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; @@ -1408,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); @@ -1597,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, @@ -1660,7 +2031,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const row = selectedCell ? findRow(selectedCell.rowKey) : null; const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const selection = - row && cell && row.rowType !== "section" + row && cell && isGridInsertionRowType(row.rowType) ? { rowType: row.rowType, cellKind: cell.kind, @@ -1791,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( @@ -1993,6 +2364,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); } + function resolveDeviceRowCircuitDropIntent( + event: DragEvent, + circuitId: string, + sectionId: string, + requiresConfirmation: boolean + ): DeviceRowMoveDropIntent { + const rect = ( + event.currentTarget as HTMLTableRowElement + ).getBoundingClientRect(); + const relativeY = + rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + if (relativeY <= 0.25 || relativeY >= 0.75) { + return { + kind: "move-to-new-circuit", + sectionId, + requiresConfirmation, + targetCircuitId: circuitId, + placement: relativeY <= 0.25 ? "before" : "after", + }; + } + return { + kind: "move-to-circuit", + circuitId, + sectionId, + requiresConfirmation, + }; + } + // Applies same-section circuit reorder as explicit id ordering. // No implicit renumbering is performed here. function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { @@ -2174,15 +2573,15 @@ 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 = - targetSection.circuits.length > 0 - ? Math.max( - ...targetSection.circuits.map( - (circuit) => circuit.sortOrder - ) - ) + 10 - : 10; + intent.targetCircuitId && intent.placement + ? getAdjacentInsertionSortOrder( + targetSection.circuits, + intent.targetCircuitId, + intent.placement + ) + : getInsertionSortOrder(targetSection.circuits); const targetCircuit = createCircuitSnapshot({ sectionId: intent.sectionId, equipmentIdentifier: next.nextIdentifier, @@ -2211,7 +2610,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - // Handles circuit drag intent and reorders whole circuit blocks within one section only. + // Reorders whole circuit blocks within a group or moves one complete circuit + // to another group of the same category. async function handleCircuitReorderDrop(event: DragEvent, intent: CircuitReorderDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -2225,8 +2625,75 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Der gezogene Stromkreis fehlt."); return; } + const sourceSectionIds = sourceCircuitIds + .map((id) => findCircuitSectionId(id)) + .filter((id): id is string => Boolean(id)); + const isCrossGroupMove = + sourceSectionIds.length === 1 && + sourceSectionIds[0] !== intent.sectionId; + if (isCrossGroupMove) { + if ( + sourceCircuitIds.length !== 1 || + !canMoveCircuitToGroup( + data?.sections ?? [], + sourceCircuitIds[0], + intent.sectionId + ) + ) { + setError( + "Gruppenübergreifend kann genau ein Stromkreis in eine Gruppe derselben Kategorie verschoben werden." + ); + return; + } + let plan: ReturnType; + try { + plan = buildCircuitGroupMovePlan({ + sections: data?.sections ?? [], + circuitId: sourceCircuitIds[0], + targetSectionId: intent.sectionId, + placement: + intent.kind === "section-end" + ? { kind: "end" } + : { + kind: + intent.kind === "before-circuit" + ? "before" + : "after", + targetCircuitId: intent.targetCircuitId, + }, + }); + } catch (err) { + setError(normalizeUiError(err)); + return; + } + const selectionIntent: SelectionIntent = { + rowKey: `circuitSummary:${plan.circuitId}`, + cellKey: "equipmentIdentifier", + rowType: "circuitSummary", + sectionId: plan.targetSectionId, + circuitId: plan.circuitId, + }; + await runCommand({ + label: "Stromkreis in andere Gruppe verschieben", + redo: async () => { + const result = await moveCircuitToGroupCommand( + projectId, + getExpectedProjectRevision(), + plan + ); + applyProjectCommandResult(result); + pendingSelectedCircuitIdsAfterReload.current = [ + plan.circuitId, + ]; + return selectionIntent; + }, + }); + return; + } if (!intent.valid) { - setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); + setError( + "Stromkreise können nur innerhalb einer Gruppe oder einzeln zwischen Gruppen derselben Kategorie verschoben werden." + ); return; } const section = data?.sections.find((entry) => entry.id === intent.sectionId); @@ -2236,7 +2703,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id)); if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) { - setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); + setError( + "Die ausgewählten Stromkreise gehören nicht zur Zielgruppe." + ); return; } const primaryCircuitId = sourceCircuitIds[0]; @@ -2351,7 +2820,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const row = selectedCell ? findRow(selectedCell.rowKey) : null; const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const intent = resolveGridDeleteIntent( - row && cell && row.rowType !== "section" + row && cell && isGridInsertionRowType(row.rowType) ? { rowType: row.rowType, cellKind: cell.kind, @@ -2579,6 +3048,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const activeDraggedCircuitIds = draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; const draggingCircuitCount = activeDraggedCircuitIds.length; + const isCircuitDropTargetValid = (targetSectionId: string) => { + const sourceSectionIds = activeDraggedCircuitIds + .map((id) => findCircuitSectionId(id)) + .filter((id): id is string => Boolean(id)); + return ( + (sourceSectionIds.length > 0 && + sourceSectionIds.every( + (sectionId) => sectionId === targetSectionId + )) || + (activeDraggedCircuitIds.length === 1 && + canMoveCircuitToGroup( + data.sections, + activeDraggedCircuitIds[0], + targetSectionId + )) + ); + }; const selectedProjectDevice = resolveSelectedProjectDevice(); const suggestedSection = selectedProjectDevice ? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice)) @@ -2601,6 +3087,58 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
+ {componentEditorIntent ? ( + setComponentEditorIntent(null)} + onSave={handleSaveDistributionBoardComponent} + role={ + componentEditorIntent.kind === "edit" + ? (componentEditorIntent.component + .role as MutableDistributionBoardComponentRole) + : componentEditorIntent.role + } + /> + ) : null} + {circuitGroupEditorIntent ? ( + setCircuitGroupEditorIntent(null)} + onSave={handleSaveCircuitGroup} + /> + ) : null} + {protectionEditorCircuit ? ( + + section.id === protectionEditorCircuit.sectionId + )?.category, + protectionEditorCircuit.protectionDevice + )} + isSaving={isSaving} + onClose={() => setProtectionEditorCircuit(null)} + onSave={handleSaveCircuitProtection} + /> + ) : null}
+ ))} {searchableProjectDevices.length === 0 ?

Keine passenden Projektgeräte gefunden.

: null} @@ -2986,8 +3542,101 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {visibleRows.map((row) => { + if ( + row.rowType === "headerComponent" || + row.rowType === "groupComponent" || + row.rowType === "footerComponent" + ) { + const component = row.component!; + const isMutableComponent = + component.role === "group_upstream_protection" || + component.role === "group_residual_current_protection" || + component.role === "auxiliary"; + const protection = component.protectionDevice; + const protectionSummary = protection + ? [ + protection.type, + `${formatValue( + protection.ratedCurrentA, + "protectionRatedCurrent" + )} A`, + protection.fuseUtilizationCategory, + protection.tripCharacteristic, + protection.rcdType + ? `Typ ${protection.rcdType}` + : undefined, + protection.ratedResidualCurrentMa !== undefined + ? `${formatValue( + protection.ratedResidualCurrentMa, + "protectionRatedCurrent" + )} mA` + : undefined, + ] + .filter(Boolean) + .join(" · ") + : null; + return ( + + +
+ {component.equipmentIdentifier} + {component.name} + {protectionSummary ? ( + + {protectionSummary} + + ) : null} + + {isMutableComponent ? ( + <> + + + + ) : ( + + Feste Verteilerkomponente + + )} + +
+ + + ); + } if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; + const hasUpstreamProtection = section.components.some( + (component) => + component.role === "group_upstream_protection" + ); + const hasGroupRcd = section.components.some( + (component) => + component.role === "group_residual_current_protection" + ); return ( { if (draggingCircuitCount > 0) { + const valid = isCircuitDropTargetValid( + section.id + ); event.preventDefault(); - event.dataTransfer.dropEffect = "none"; + event.dataTransfer.dropEffect = valid + ? "move" + : "none"; setCircuitReorderIntent({ kind: "section-end", sectionId: section.id, - valid: false, + valid, }); return; } @@ -3042,7 +3696,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void handleCircuitReorderDrop(event, { kind: "section-end", sectionId: section.id, - valid: false, + valid: isCircuitDropTargetValid(section.id), }); } }} @@ -3063,13 +3717,155 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
- + + + + + + + ) : null} @@ -3657,13 +4512,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {`${draggingDeviceCount || 1} Gerätezeile(n) in diesen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`} ) : null} + {deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.targetCircuitId === + row.circuit?.id ? ( + + {`${draggingDeviceCount || 1} Gerätezeile(n) in einen neuen Stromkreis ${ + deviceMoveIntent.placement === "before" + ? "davor" + : "danach" + } verschieben${ + deviceMoveIntent.requiresConfirmation + ? " (Bestätigung erforderlich)" + : "" + }`} + + ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( {circuitReorderIntent.valid ? `${draggingCircuitCount || 1} Stromkreis(e) ans Bereichsende verschieben` - : "Bereichsübergreifendes Verschieben nicht zulässig"} + : "Nur ein Stromkreis kann zwischen Gruppen derselben Kategorie verschoben werden"} ) : null} {circuitReorderIntent && @@ -3674,7 +4544,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ? circuitReorderIntent.kind === "before-circuit" ? `${draggingCircuitCount || 1} Stromkreis(e) vor diesen Stromkreis verschieben` : `${draggingCircuitCount || 1} Stromkreis(e) hinter diesen Stromkreis verschieben` - : "Bereichsübergreifendes Verschieben nicht zulässig"} + : "Nur ein Stromkreis kann zwischen Gruppen derselben Kategorie verschoben werden"} ) : null} diff --git a/src/frontend/components/circuit-tree-preview.tsx b/src/frontend/components/circuit-tree-preview.tsx deleted file mode 100644 index 2e5a5d2..0000000 --- a/src/frontend/components/circuit-tree-preview.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Fragment } from "react"; -import { getCircuitTree } from "../utils/api"; -import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types"; -import { - formatValue, - getCircuitSectionLabel, -} from "../utils/circuit-grid-model"; - -function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) { - if (circuit.displayName?.trim()) { - return circuit.displayName; - } - if (circuit.deviceRows.length > 1) { - return `${circuit.deviceRows.length} Geräte`; - } - return "Reserve"; -} - -export function CircuitTreePreview(props: { projectId: string; circuitListId: string }) { - const { projectId, circuitListId } = props; - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - setIsLoading(true); - setError(null); - getCircuitTree(projectId, circuitListId) - .then(setData) - .catch((err: unknown) => - setError(err instanceof Error ? err.message : "Die Stromkreisstruktur konnte nicht geladen werden.") - ) - .finally(() => setIsLoading(false)); - }, [projectId, circuitListId]); - - if (isLoading) { - return
Stromkreisstruktur wird geladen …
; - } - - if (error) { - return
{error}
; - } - - if (!data || !data.sections.length) { - return
Keine Bereiche oder Stromkreise vorhanden.
; - } - - const hasAnyCircuits = data.sections.some((section) => section.circuits.length > 0); - if (!hasAnyCircuits) { - return
Bereiche sind vorhanden, enthalten aber noch keine Stromkreise.
; - } - - return ( -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - {data.sections.map((section) => ( - - ))} - -
BetriebsmittelkennzeichenAnzeigenamePhasenartAnschlussartKostengruppeKategorieEbeneRaumnummerRaumnameAnzahlLeistung / Gerät [kW]Gleichzeitigkeitcos φGesamtsumme [kW]SchutzartBemessungsstromCharakteristikKabeltypKabelquerschnittKabellängeBemerkung
-
-
-
- ); -} - -function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number] }) { - const { section } = props; - return ( - <> - - - {getCircuitSectionLabel(section)} - - - {section.circuits.map((circuit) => { - if (circuit.deviceRows.length === 0) { - return ( - - {circuit.equipmentIdentifier} - {circuit.displayName?.trim() || "Reserve"} - - - - {formatValue(circuit.circuitTotalPower, "circuitTotalPower")} - - {circuit.protectionType ?? "-"} - - {formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")} - - {circuit.protectionCharacteristic ?? "-"} - {circuit.cableType ?? "-"} - {circuit.cableCrossSection ?? "-"} - {formatValue(circuit.cableLength, "cableLength")} - {circuit.remark ?? "-"} - - ); - } - - if (circuit.deviceRows.length === 1) { - const row = circuit.deviceRows[0]; - return ( - - {circuit.equipmentIdentifier} - {row.displayName || row.name} - {formatValue(row.phaseType ?? undefined, "phaseType")} - {row.connectionKind ?? "-"} - {row.costGroup ?? "-"} - {row.category ?? "-"} - {row.level ?? "-"} - {row.roomNumberSnapshot ?? "-"} - {row.roomNameSnapshot ?? "-"} - {formatValue(row.quantity, "quantity")} - {formatValue(row.powerPerUnit, "powerPerUnit")} - - {formatValue(row.simultaneityFactor, "simultaneityFactor")} - - {formatValue(row.cosPhi, "cosPhi")} - {formatValue(row.rowTotalPower, "rowTotalPower")} - {circuit.protectionType ?? "-"} - - {formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")} - - {circuit.protectionCharacteristic ?? "-"} - {circuit.cableType ?? "-"} - {circuit.cableCrossSection ?? "-"} - {formatValue(circuit.cableLength, "cableLength")} - {row.remark ?? circuit.remark ?? "-"} - - ); - } - - return ( - - - {circuit.equipmentIdentifier} - {renderCircuitSummaryLabel(circuit)} - - - - {formatValue(circuit.circuitTotalPower, "circuitTotalPower")} - - {circuit.protectionType ?? "-"} - - {formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")} - - {circuit.protectionCharacteristic ?? "-"} - {circuit.cableType ?? "-"} - {circuit.cableCrossSection ?? "-"} - {formatValue(circuit.cableLength, "cableLength")} - {circuit.remark ?? "-"} - - {circuit.deviceRows.map((row) => ( - - - {row.displayName || row.name} - {formatValue(row.phaseType ?? undefined, "phaseType")} - {row.connectionKind ?? "-"} - {row.costGroup ?? "-"} - {row.category ?? "-"} - {row.level ?? "-"} - {row.roomNumberSnapshot ?? "-"} - {row.roomNameSnapshot ?? "-"} - {formatValue(row.quantity, "quantity")} - {formatValue(row.powerPerUnit, "powerPerUnit")} - - {formatValue(row.simultaneityFactor, "simultaneityFactor")} - - {formatValue(row.cosPhi, "cosPhi")} - {formatValue(row.rowTotalPower, "rowTotalPower")} - - - - - - - - - - - - - {row.remark ?? "-"} - - ))} - - ); - })} - - -frei- - Freie Zeile - - - ); -} diff --git a/src/frontend/components/distribution-board-component-modal.tsx b/src/frontend/components/distribution-board-component-modal.tsx new file mode 100644 index 0000000..332d800 --- /dev/null +++ b/src/frontend/components/distribution-board-component-modal.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { + allowedRatedCurrentsAByProtectionDeviceType, + breakerTripCharacteristics, + fuseProtectionDeviceTypes, + fuseUtilizationCategories, + protectionDeviceTypeLabels, + ratedResidualCurrentsMa, + rcdTypes, + type ProtectionDeviceType, +} from "../../shared/constants/protection-device"; +import type { CircuitTreeComponentDto } from "../types"; +import type { + DistributionBoardComponentEditorValues, + MutableDistributionBoardComponentRole, +} from "../utils/distribution-board-component-editing"; +import { FormModal } from "./form-modal"; + +interface DistributionBoardComponentModalProps { + initialComponent?: CircuitTreeComponentDto; + initialEquipmentIdentifier?: string; + isSaving: boolean; + onClose: () => void; + onSave: (values: DistributionBoardComponentEditorValues) => Promise; + role: MutableDistributionBoardComponentRole; +} + +const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [ + ...fuseProtectionDeviceTypes, + "LS", +]; + +export function DistributionBoardComponentModal({ + initialComponent, + initialEquipmentIdentifier = "", + isSaving, + onClose, + onSave, + role, +}: DistributionBoardComponentModalProps) { + const [equipmentIdentifier, setEquipmentIdentifier] = useState( + initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier + ); + const [name, setName] = useState( + initialComponent?.name ?? defaultName(role) + ); + const initialProtection = initialComponent?.protectionDevice; + const [protectionType, setProtectionType] = useState< + ProtectionDeviceType | "" + >( + initialProtection?.type ?? + (role === "group_residual_current_protection" ? "FI" : "") + ); + const [ratedCurrentA, setRatedCurrentA] = useState( + initialProtection?.ratedCurrentA ?? + (role === "group_residual_current_protection" ? 40 : 0) + ); + const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState( + initialProtection?.fuseUtilizationCategory ?? "gG" + ); + const [tripCharacteristic, setTripCharacteristic] = useState( + initialProtection?.tripCharacteristic ?? "B" + ); + const [rcdType, setRcdType] = useState( + initialProtection?.rcdType ?? "A" + ); + const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState( + initialProtection?.ratedResidualCurrentMa ?? 30 + ); + + const isAuxiliary = role === "auxiliary"; + const availableProtectionTypes = + role === "group_residual_current_protection" + ? (["FI"] as const) + : upstreamProtectionTypes; + const usesFuseCategory = + protectionType !== "" && + (fuseProtectionDeviceTypes as readonly string[]).includes(protectionType); + const usesTripCharacteristic = protectionType === "LS"; + const usesResidualCurrent = protectionType === "FI"; + const allowedRatedCurrents = + protectionType === "" + ? [] + : allowedRatedCurrentsAByProtectionDeviceType[protectionType]; + + function handleProtectionTypeChange(type: ProtectionDeviceType) { + setProtectionType(type); + setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]); + setFuseUtilizationCategory("gG"); + setTripCharacteristic("B"); + setRcdType("A"); + setRatedResidualCurrentMa(30); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + equipmentIdentifier, + name, + ...(!isAuxiliary && protectionType + ? { + protectionDevice: { + type: protectionType, + ratedCurrentA, + ...(usesFuseCategory + ? { fuseUtilizationCategory } + : {}), + ...(usesTripCharacteristic + ? { tripCharacteristic } + : {}), + ...(usesResidualCurrent + ? { rcdType, ratedResidualCurrentMa } + : {}), + }, + } + : {}), + }); + } + + const isValid = + equipmentIdentifier.trim().length > 0 && + name.trim().length > 0 && + (isAuxiliary || + (protectionType !== "" && + (allowedRatedCurrents as readonly number[]).includes(ratedCurrentA))); + + return ( + +
+
+ + setEquipmentIdentifier(event.target.value)} + required + value={equipmentIdentifier} + /> +
+
+ + setName(event.target.value)} + required + value={name} + /> +
+ {!isAuxiliary ? ( + <> +
+ + +
+
+ + +
+ {usesFuseCategory ? ( +
+ + +
+ ) : null} + {usesTripCharacteristic ? ( +
+ + +
+ ) : null} + {usesResidualCurrent ? ( + <> +
+ + +
+
+ + +
+ + ) : null} + + ) : null} +
+
+ ); +} + +function defaultName(role: MutableDistributionBoardComponentRole): string { + if (role === "group_upstream_protection") { + return "Gruppenvorsicherung"; + } + if (role === "group_residual_current_protection") { + return "Gruppen-FI"; + } + return "Verteilergerät"; +} diff --git a/src/frontend/components/form-modal.tsx b/src/frontend/components/form-modal.tsx index 43aa10e..d893690 100644 --- a/src/frontend/components/form-modal.tsx +++ b/src/frontend/components/form-modal.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { type FormEvent, type ReactNode } from "react"; +import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react"; interface FormModalProps { children: ReactNode; description?: string; + dialogSize?: "large" | "viewport"; isSaving: boolean; onClose: () => void; onSubmit: (event: FormEvent) => void; @@ -13,9 +14,13 @@ 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, + dialogSize = "large", isSaving, onClose, onSubmit, @@ -23,15 +28,63 @@ 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/gzf-picker-modal.tsx b/src/frontend/components/gzf-picker-modal.tsx new file mode 100644 index 0000000..5f118a6 --- /dev/null +++ b/src/frontend/components/gzf-picker-modal.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf"; + +interface GzfPickerModalProps { + onClose: () => void; + onSelect: (value: number) => void; +} + +function formatFactor(value: number) { + return value.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) { + return ( + <> +
+
+
+
+
+

Gleichzeitigkeitsfaktor nach AMEV

+

+ Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen +

+
+
+
+
+ + + + + + + + + + + + {AMEV_GZF_TABLE.map((entry) => ( + + + + + + + + ))} + +
VerbrauchergruppeBereichMittelwertGfgesAktion
{entry.group}{entry.range}{formatFactor(entry.mean)} + {entry.overallRange ?? "–"} + + +
+
+

+ Quelle: AMEV „Planungshilfe für elektrische Leistungsbilanzen", Tabelle 3. + Übernommen wird jeweils der Mittelwert Gf1 der angegebenen Bandbreite; „Gfges" + ist der alternative Gesamtgleichzeitigkeitsfaktor für Verwaltungsgebäude. +

+
+
+ +
+
+
+
+
+ + ); +} diff --git a/src/frontend/components/project-device-modal.tsx b/src/frontend/components/project-device-modal.tsx index 907fffe..27c24d0 100644 --- a/src/frontend/components/project-device-modal.tsx +++ b/src/frontend/components/project-device-modal.tsx @@ -7,10 +7,17 @@ import type { ProjectDeviceDto, } from "../types"; import { FormModal } from "./form-modal"; +import { + circuitGroupCategories, + circuitGroupCategoryLabels, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group"; +import { GzfPickerModal } from "./gzf-picker-modal"; interface ProjectDeviceModalProps { globalDevices: GlobalDeviceDto[]; initialDevice?: ProjectDeviceDto; + initialValues?: Partial; isSaving: boolean; onClose: () => void; onImportGlobal: (globalDeviceId: string) => Promise; @@ -20,13 +27,15 @@ interface ProjectDeviceModalProps { export function ProjectDeviceModal({ globalDevices, initialDevice, + initialValues, isSaving, onClose, onImportGlobal, onSave, }: ProjectDeviceModalProps) { - const [values, setValues] = useState(() => toFormValues(initialDevice)); + const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues)); const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState(""); + const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false); function update(key: keyof typeof values, value: string) { setValues((current) => ({ ...current, [key]: value })); @@ -37,14 +46,12 @@ export function ProjectDeviceModal({ await onSave({ name: values.name.trim(), displayName: values.displayName.trim() || values.name.trim(), - phaseType: - values.phaseType === "three_phase" ? "three_phase" : "single_phase", connectionKind: optionalString(values.connectionKind), costGroup: optionalString(values.costGroup), - category: optionalString(values.category), - quantity: Number(values.quantity), - powerPerUnit: Number(values.powerPerUnit), - simultaneityFactor: Number(values.simultaneityFactor), + category: values.category as CircuitGroupCategory, + quantity: parseDecimal(values.quantity), + powerPerUnit: parseDecimal(values.powerPerUnit), + simultaneityFactor: parseDecimal(values.simultaneityFactor), cosPhi: optionalNumber(values.cosPhi), remark: optionalString(values.remark), }); @@ -52,12 +59,13 @@ export function ProjectDeviceModal({ const isValid = values.name.trim().length > 0 && - Number(values.quantity) >= 0 && - Number(values.powerPerUnit) >= 0 && - Number(values.simultaneityFactor) >= 0 && - Number(values.simultaneityFactor) <= 1; + isNumberInRange(values.quantity, 0) && + isNumberInRange(values.powerPerUnit, 0) && + isNumberInRange(values.simultaneityFactor, 0, 1) && + (!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1)); return ( + <> update("displayName", value)} value={values.displayName} /> - update("category", value)} - value={values.category} - /> +
+ + +
update("costGroup", value)} value={values.costGroup} /> -
- - -
- update("simultaneityFactor", value)} - step="0.01" - value={values.simultaneityFactor} - /> +
+ +
+ update("simultaneityFactor", event.target.value)} + step="0.01" + type="number" + value={values.simultaneityFactor} + /> + +
+
+ {isGzfPickerOpen ? ( + setIsGzfPickerOpen(false)} + onSelect={(value) => { + update("simultaneityFactor", String(value)); + setIsGzfPickerOpen(false); + }} + /> + ) : null} + ); } @@ -244,26 +277,29 @@ function NumberField({ min={min} onChange={(event) => onChange(event.target.value)} step={step} - type="number" + inputMode="decimal" + type="text" value={value} />
); } -function toFormValues(device?: ProjectDeviceDto) { +function toFormValues( + device?: ProjectDeviceDto, + initialValues?: Partial +) { return { - name: device?.name ?? "", - displayName: device?.displayName ?? "", - phaseType: device?.phaseType ?? "single_phase", - connectionKind: device?.connectionKind ?? "", - costGroup: device?.costGroup ?? "", - category: device?.category ?? "", - quantity: String(device?.quantity ?? 1), - powerPerUnit: String(device?.powerPerUnit ?? 0.1), - simultaneityFactor: String(device?.simultaneityFactor ?? 1), - cosPhi: String(device?.cosPhi ?? 1), - remark: device?.remark ?? "", + name: device?.name ?? initialValues?.name ?? "", + displayName: device?.displayName ?? initialValues?.displayName ?? "", + connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "", + costGroup: device?.costGroup ?? initialValues?.costGroup ?? "", + category: device?.category ?? initialValues?.category ?? "single_phase", + quantity: String(device?.quantity ?? initialValues?.quantity ?? 1), + powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1), + simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1), + cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1), + remark: device?.remark ?? initialValues?.remark ?? "", }; } @@ -272,5 +308,14 @@ function optionalString(value: string) { } function optionalNumber(value: string) { - return value.trim() ? Number(value) : undefined; + return value.trim() ? parseDecimal(value) : undefined; +} + +function parseDecimal(value: string) { + return Number(value.trim().replace(",", ".")); +} + +function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) { + const parsed = parseDecimal(value); + return Number.isFinite(parsed) && parsed >= min && parsed <= max; } diff --git a/src/frontend/components/project-settings-modal.tsx b/src/frontend/components/project-settings-modal.tsx index 228f600..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; @@ -14,6 +15,7 @@ export interface ProjectSettingsInput { externalProjectNumber: string | null; buildingOwner: string | null; description: string | null; + isPublicBuilding: boolean; singlePhaseVoltageV: number; threePhaseVoltageV: number; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; @@ -54,6 +56,9 @@ export function ProjectSettingsModal({ const [description, setDescription] = useState( project.description ?? "" ); + const [isPublicBuilding, setIsPublicBuilding] = useState( + project.isPublicBuilding + ); const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState( String(project.singlePhaseVoltageV) ); @@ -82,6 +87,7 @@ export function ProjectSettingsModal({ externalProjectNumber: toNullableString(externalProjectNumber), buildingOwner: toNullableString(buildingOwner), description: toNullableString(description), + isPublicBuilding, singlePhaseVoltageV: Number(singlePhaseVoltageV), threePhaseVoltageV: Number(threePhaseVoltageV), enabledDistributionBoardSupplyTypes, @@ -126,298 +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.
-
+
+ + setBuildingOwner(event.target.value)} + value={buildingOwner} + /> +
+
+ +