diff --git a/AGENTS.md b/AGENTS.md index f1a509d..c00fd78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,19 +164,53 @@ Keyboard behavior: - Escape cancels - Tab / Shift+Tab navigates editable cells - Arrow keys navigate cells when not editing +- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the + selected GZF cell also shows a small table-icon button. The picked value is + committed like a typed edit. Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. +Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`, +`lucide-react` icons). The German action text is always the accessible name and +tooltip; the toolbar shows icon plus text, group headers and rows show icons +only, separated into logical blocks with `IconButtonSeparator`. Destructive +actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for +removing a device row). +Column headers show the label (click to sort) with a funnel icon for the +value filter; an active filter is highlighted and shows its value count. +Header labels break only between words and put a trailing unit such as +`[kW]` on a second line. +The project overview uses the same rule: row actions are icon-only +`IconButton`s, card-header and panel actions show icon plus text, navigation +uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons +stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter +through a collapsible PM-style filter bar (`CollapsibleFilterPanel`, +`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only +the bar collapses, its state is kept per card in `localStorage`, a collapsed +active filter shows "Filter aktiv". + ## Bootstrap / Styling Rule Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals. +Cards follow the PM app look: 1px border all around, 16px radius and the +soft shadow defined on `.card` in `globals.css` (own dark-mode value). The +`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other +`shadow-*` classes or a colored top border to cards. + Do not use Bootstrap as the core table/grid framework for the circuit list editor. The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior. Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing. +## Browser Runtime + +The web UI is also opened over plain HTTP from other machines in the LAN, which +is not a secure context. Do not call secure-context-only browser APIs such as +`crypto.randomUUID()` directly; create client-side ids with `createClientId()` +from `src/frontend/utils/client-id.ts`. + ## Drag-and-Drop Rules Dragging from the circuit identifier / circuit handle moves the whole circuit. diff --git a/README.md b/README.md index 58f1832..55c4f65 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise, Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. -Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb -mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, -Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht -implementiert. +Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein +Docker-Server-Stack mit gebautem Anwendungscode und ein separater +Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment +und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau +und bestätigter Erstimport sind implementiert; die Editoroberfläche zur +Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. ## Unterstützter Arbeitsablauf @@ -25,6 +27,9 @@ implementiert. - Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen - benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen - automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen +- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von + Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen + noch keine Stromkreise oder Gerätezeilen ## Technik diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index c8c02aa..8050cfe 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -1,5 +1,8 @@ # Circuit List Editor Interactions +The back-arrow link beside the page title returns to the project's distribution +board overview without changing circuit-list data. + ## Editing Model Inline cells are static text by default. A cell enters edit mode by: @@ -44,6 +47,7 @@ Selection, keyboard movement, and editability checks run against this normalized - arrow keys: move selected cell in grid when not editing - type-to-edit: printable keys open edit mode and replace current display value with typed character - `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context +- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table - `Delete`: delete the selected circuit or device context after confirmation For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers. @@ -52,6 +56,23 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction. +## AMEV Simultaneity Factor + +The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open +the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project +device dialog uses as well. "Übernehmen" commits the mean value of the chosen +consumer group through the same path as a typed edit (`commitEdit` with an +explicit target cell), so it is one undoable command and works on device rows, +compact circuits, reserve circuits and the `-frei-` row. A running inline edit +of the cell is discarded when the picker opens. The button only appears on the +selected cell; the grid keeps its static-text cells. + +The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and +closes on Escape without writing a value. Closing returns focus to the grid; +only "Übernehmen" saves the selected mean value. Opening the picker is disabled +while a write or history operation is running. Its cell button stays outside +the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell. + ## Equipment Identifier Validation Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace. @@ -127,11 +148,30 @@ Cross-section device moves show confirmation-required feedback before drop. The ## Filtering and Sorting +- Clicking a column label changes sorting; the funnel icon next to it opens + the value filter. An active filter is highlighted and shows the number of + selected values. Both controls retain German accessible names and tooltips. +- Header labels wrap between words; trailing units such as `[kW]` appear on + a separate line. - Per-column filtering works on normalized displayed values. - Sorting is view-level first and treats circuits as blocks. - Multi-device circuits are not split during sort. - Sorting alone does not persist order. +## Action Buttons + +The editor toolbar uses icons with visible German labels. Group headers and +circuit/device rows use compact icon buttons with German accessible names and +tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish +their roles. Dividers separate related actions; destructive actions use the +danger style. Circuit deletion uses a trash icon, while device-row deletion +uses a crossed-out list icon. + +The shared `src/frontend/components/icon-button.tsx` component preserves +disabled states and expanded states for toggles. Row and group action buttons +retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains +unchanged. The existing command handlers and deletion confirmations are reused. + ## Apply Sorted Order `Apply sorted order` persists current sorted block order to backend by section. @@ -161,6 +201,11 @@ across multiple sections is atomic and occupies one history step. Explicit renumbering uses a collision-safe persistent project command and one project-history step. +The editor creates client-side UUIDs through `createClientId` in +`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available +and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion +also works over plain HTTP from another machine in the LAN. + Covered operations include: - insert/delete circuit diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 92efc98..ab223bd 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,9 +27,18 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000 data/leistungsbilanz.db (SQLite) ``` -Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das -Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank -liegt über einen Host-Mount außerhalb des Containers. +In beiden Docker-Stacks laufen Frontend und API in getrennten Containern. +`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung +mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API +weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die +Datenbank liegt über einen Host-Mount außerhalb des Containers. + +Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt +der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die +Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls +`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen +auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen +vor dem Command und bleiben über Undo/Redo unverändert. ## Wichtige Einstiegspunkte @@ -252,6 +261,31 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, damit Änderungen weiterhin niemals still synchronisiert werden. +Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare +Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw. +Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die +Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der +Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor +`IconButton`/`IconLink`. +Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen +gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener +Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben +Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente. +Die Verteilersuche berücksichtigt aktuell Name und Etagenname; die Gerätesuche +Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart +und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig. +Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter +und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende +Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise +„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei +der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte. +Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar. +Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe +und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine +Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts +erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den +bestehenden persistenten Projektbefehl. + `GET /api/projects/:projectId/export` verpackt denselben vollständigen Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, diff --git a/docs/deployment.md b/docs/deployment.md index 909c2c0..0b5baba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,9 +16,10 @@ 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. +Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten +Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten +Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf +deshalb nicht öffentlich erreichbar gemacht werden. ## Topologie @@ -34,6 +35,11 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. +Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue +UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das +dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese +ID-Erzeugung nicht erforderlich. + ### Speichergrenzen Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich @@ -140,14 +146,17 @@ Vor einer produktiven Installation werden mindestens benötigt: - Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für Mehrbenutzerbetrieb erforderlich ist -Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem -lokalen Entwicklungsstart in der README. +Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des +gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes +Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen. ## Backup und Wiederherstellung `npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup, öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese -Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt. +Dateisicherungen sind von den bereits vorhandenen benannten und automatischen +logischen Projektsicherungen getrennt. Projektsicherungen werden über die +Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo. Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei diff --git a/docs/development-workflow.md b/docs/development-workflow.md index b19b7ff..f630063 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -13,6 +13,11 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden `data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch angelegt. Das Frontend ist unter erreichbar. +Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung +mit Quellcode-Mounts und Hot Reload stattdessen +`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste +sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar. + Für direkte Node.js-Entwicklung: ```powershell @@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich: ```powershell docker compose up --build --detach docker compose ps -Invoke-WebRequest http://localhost:3000/health +Invoke-WebRequest http://localhost:3001/health +Invoke-WebRequest http://localhost:3001/web-health ``` Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich. diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..a8a382d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -381,6 +381,263 @@ a { font-weight: 600; } +/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ───── + Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen + kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der + PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an + die Tokens dieser App. */ + +.btn-with-icon { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.overview-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; +} + +.overview-actions .icon-button { + min-width: 1.9rem; + min-height: 1.85rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.overview-actions .icon-button:disabled { + opacity: 0.45; +} + +.overview-actions .icon-button-primary { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +.overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-primary-dark); + background: var(--color-primary-dark); + color: #fff; +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary { + border-color: var(--color-accent); + background: var(--color-accent); +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-accent-pale); + background: var(--color-accent-pale); + color: var(--color-primary-deep); +} + +.overview-actions .icon-button-danger { + color: var(--color-danger); +} + +.filter-panel { + padding: 0.55rem 1rem 0.7rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.filter-panel-header { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-panel-header .filter-panel-toggle { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: none; + color: var(--color-ink-soft); +} + +.filter-panel-header .filter-panel-toggle:hover { + background: var(--color-surface); +} + +.filter-panel-label, +.filter-bar-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-ink-soft); +} + +.filter-panel-badge { + padding: 3px 10px; + border-radius: 999px; + background: #fbe9d9; + color: var(--color-attention-dark); + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; +} + +:root[data-bs-theme="dark"] .filter-panel-badge { + background: #3a2e1c; + color: #f0b880; +} + +.filter-panel-count { + margin-left: auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-sm) var(--space-md); + margin-top: 0.5rem; +} + +.filter-bar-field, +.filter-dropdown { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; +} + +.filter-bar-search, +.filter-dropdown-toggle { + min-height: 2.1rem; + padding: 0.35rem 0.65rem; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font: inherit; + font-size: 0.82rem; +} + +.filter-bar-search { + width: 15em; +} + +.filter-bar-search:focus, +.filter-dropdown-toggle:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; + border-color: var(--color-accent); +} + +.filter-dropdown-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 11em; + max-width: 18em; + font-weight: 500; + cursor: pointer; +} + +.filter-dropdown-toggle:hover, +.filter-dropdown-toggle.is-active { + border-color: var(--color-accent); +} + +.filter-dropdown-toggle.is-active { + color: var(--color-primary); +} + +:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active { + color: var(--color-accent-pale); +} + +.filter-dropdown-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-dropdown-toggle svg { + flex: none; + color: var(--color-ink-soft); +} + +.filter-dropdown-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + padding: 8px; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + box-shadow: 0 6px 20px var(--shadow-menu); +} + +.filter-dropdown-option { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 8px; + border-radius: 6px; + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 400; + cursor: pointer; +} + +.filter-dropdown-option:hover { + background: var(--color-surface); +} + +.filter-dropdown-option input { + accent-color: var(--color-primary); +} + +.filter-dropdown-option-all { + margin-bottom: 2px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); + border-radius: 6px 6px 0 0; +} + +.filter-dropdown-empty { + padding: 6px 8px; + color: var(--color-ink-soft); + font-size: 0.8rem; +} + +.filter-bar .filter-bar-reset { + min-height: 2.1rem; + padding: 0.3rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; +} + /* ── KPI-Kacheln ─────────────────────────────────────────────────────── */ .kpi-grid { @@ -431,12 +688,29 @@ a.kpi:hover { color: var(--color-accent-pale); } +/* Flächen-Look wie in der PM-App (Vorbild grow-monitor): gleichmäßiger + 1-px-Rahmen statt farbigem Balken an der Oberkante, größerer Radius und ein + weicher Streuschatten mit feiner heller Innenkante oben. Der Akzent kommt + über Kopfzeile und Inhalt, nicht über die Kante. Bootstraps shadow-sm + setzt box-shadow mit !important aus --bs-box-shadow-sm; die Karte legt diese + Variable auf denselben Schatten, damit Karten mit und ohne shadow-sm gleich + aussehen. */ .card { - 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); + --lb-card-shadow: + 0 4px 24px var(--shadow-soft), + 0 1px 0 rgba(255, 255, 255, 0.7) inset; + --bs-box-shadow-sm: var(--lb-card-shadow); + --bs-card-border-radius: 16px; + --bs-card-border-color: var(--color-border); + box-shadow: var(--lb-card-shadow); +} + +/* Auf dunklem Grund ein kräftigerer Streuschatten und nur eine schwache + Innenkante, sonst wirkt sie wie ein harter weißer Strich. */ +:root[data-bs-theme="dark"] .card { + --lb-card-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + 0 1px 0 rgba(255, 255, 255, 0.05) inset; } .card-header { @@ -487,6 +761,54 @@ a.kpi:hover { opacity: 0.45; } +/* Icon buttons (circuit list editor toolbar, group and row actions) */ +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + line-height: 1.2; + cursor: pointer; + transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease; +} + +.icon-button svg { + flex: none; +} + +.icon-button:disabled { + cursor: not-allowed; +} + +.icon-button:not(:disabled):hover { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.icon-button-danger:not(:disabled):hover { + border-color: var(--color-danger); + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover { + color: #fff; + filter: brightness(1.08); +} + +.icon-button-text { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.icon-button-separator { + align-self: stretch; + width: 1px; + margin: 0.1rem 0.15rem; + background: var(--panel-border-strong); +} + .editor-toolbar .project-device-drawer-toggle { border-color: var(--accent-blue); background: var(--accent-blue); @@ -844,24 +1166,37 @@ a.kpi:hover { white-space: nowrap; } +/* One header row: label (sort) on the left, filter icon on the right. */ .tree-grid .header-cell { display: flex; - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; gap: 0.2rem; min-width: 0; } .tree-grid .header-filter-btn { - display: block; - width: 100%; - border: 1px solid var(--panel-border-strong); - background: var(--panel-bg); - color: var(--text-strong); + display: inline-flex; + flex: none; + align-items: center; + gap: 0.15rem; + border: 1px solid transparent; + background: transparent; + color: var(--text-faint); border-radius: 3px; - font-size: 0.75rem; - padding: 0.15rem 0.35rem; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + padding: 0.15rem 0.2rem; white-space: nowrap; + cursor: pointer; +} + +.tree-grid .header-filter-btn:hover, +.tree-grid .header-filter-btn:focus-visible { + border-color: var(--panel-border-strong); + color: var(--text-strong); } .tree-grid .header-sort-btn { @@ -879,7 +1214,10 @@ a.kpi:hover { font-size: 0.78rem; font-weight: 600; line-height: 1.15; - overflow-wrap: anywhere; + /* Break only between words; never inside a word such as "Bemerkung". */ + overflow-wrap: normal; + word-break: normal; + hyphens: manual; white-space: normal; text-align: left; cursor: pointer; @@ -919,6 +1257,18 @@ a.kpi:hover { font-size: 0.78rem; } +.tree-grid .header-cell .header-sort-btn { + flex: 1 1 auto; + width: auto; + align-items: flex-start; +} + +.tree-grid .header-unit { + display: block; + font-weight: 500; + color: var(--text-faint); +} + .tree-grid .header-sort-btn:hover span:first-child, .tree-grid .header-sort-btn:focus-visible span:first-child { text-decoration: underline; @@ -935,6 +1285,11 @@ a.kpi:hover { background: var(--accent-blue-soft-bg); } +.tree-grid .header-filter-count { + min-width: 0.7rem; + text-align: center; +} + .tree-grid .header-filter-menu { position: absolute; z-index: 7; @@ -1153,7 +1508,20 @@ a.kpi:hover { .tree-grid .section-actions { display: flex; - gap: 0.35rem; + align-items: center; + gap: 0.25rem; +} + +.tree-grid .section-actions .icon-button, +.tree-grid .action-cell .icon-button { + min-width: 1.75rem; + min-height: 1.6rem; + padding: 0.15rem 0.3rem; +} + +.tree-grid .section-actions .icon-button:disabled, +.tree-grid .action-cell .icon-button:disabled { + opacity: 0.35; } .tree-grid .section-actions button { @@ -1230,6 +1598,38 @@ a.kpi:hover { outline-offset: -2px; } +/* Picker trigger on the selected GZF cell. Positioned over the cell so the + column width does not change while the selection moves. */ +.tree-grid td:has(> .cell-picker-trigger) { + position: relative; +} + +.tree-grid .cell-picker-trigger { + position: absolute; + top: 50%; + left: 0.2rem; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border: 1px solid var(--accent-blue-strong-border); + border-radius: 3px; + padding: 0; + background: var(--panel-bg); + color: var(--color-primary); + cursor: pointer; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-picker-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-picker-trigger:hover { + background: var(--accent-blue-soft-bg); +} + .tree-grid .section-title { display: flex; align-items: baseline; @@ -1265,7 +1665,7 @@ a.kpi:hover { .tree-grid .action-cell { display: flex; - gap: 0.3rem; + gap: 0.25rem; } .tree-grid .action-cell button { 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 89679cf..90893f6 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 @@ -1,5 +1,6 @@ "use client"; +import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; @@ -10,9 +11,19 @@ export default function CircuitTreeEditPage() { return (
-
-

Stromkreisliste

-

Stromkreise und zugeordnete Geräte bearbeiten.

+
+ +
diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index fa342ea..c08b72e 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -3,6 +3,47 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + FileUp, + Library, + Link2, + ListTree, + Pencil, + Plus, + RefreshCw, + Settings, + Trash2, + Unlink, + X, +} from "lucide-react"; +import { IconButton } from "../../../frontend/components/icon-button"; +import { IconLink } from "../../../frontend/components/icon-link"; +import { + CollapsibleFilterPanel, + FilterSearchField, +} from "../../../frontend/components/filter-panel"; +import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown"; +import { + circuitGroupCategories, + circuitGroupCategoryLabels, +} from "../../../shared/constants/circuit-group"; +import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board"; +import { + buildCostGroupFilterOptions, + buildFloorFilterOptions, + buildSupplyTypeFilterOptions, + emptyDistributionBoardFilters, + emptyProjectDeviceFilters, + filterDistributionBoards, + filterProjectDevices, + isDistributionBoardFilterActive, + isProjectDeviceFilterActive, + retainAvailableFilterValues, + type DistributionBoardFilters, + type FilterOption, + type ProjectDeviceFilters, +} from "../../../frontend/utils/overview-filters"; import { copyGlobalDeviceToProject, copyDistributionBoard, @@ -77,6 +118,29 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; +const projectDeviceCategoryFilterOptions: FilterOption[] = + circuitGroupCategories.map((category) => ({ + id: category, + label: circuitGroupCategoryLabels[category], + })); + +const projectDevicePhaseTypeLabels: Record = { + single_phase: "1-phasig", + three_phase: "3-phasig", +}; + +const projectDevicePhaseTypeFilterOptions: FilterOption[] = ( + ["single_phase", "three_phase"] as const +).map((phaseType) => ({ + id: phaseType, + label: projectDevicePhaseTypeLabels[phaseType], +})); + +const boardFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:distribution-boards"; +const projectDeviceFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:project-devices"; + export default function ProjectDetailPage() { const params = useParams<{ projectId: string }>(); const [projectId, setProjectId] = useState(""); @@ -113,7 +177,11 @@ export default function ProjectDetailPage() { useState(false); const [editingProjectDevice, setEditingProjectDevice] = useState(null); - const [projectDeviceQuery, setProjectDeviceQuery] = useState(""); + const [boardFilters, setBoardFilters] = useState( + emptyDistributionBoardFilters + ); + const [projectDeviceFilters, setProjectDeviceFilters] = + useState(emptyProjectDeviceFilters); const [syncPreview, setSyncPreview] = useState(null); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); @@ -164,22 +232,67 @@ export default function ProjectDetailPage() { const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); - const visibleProjectDevices = useMemo(() => { - const query = projectDeviceQuery.trim().toLocaleLowerCase("de"); - if (!query) { - return projectDevices; - } - return projectDevices.filter((device) => - [ - device.name, - device.displayName, - device.category, - device.connectionKind, - device.costGroup, - ].some((value) => value?.toLocaleLowerCase("de").includes(query)) - ); - }, [projectDeviceQuery, projectDevices]); + const costGroupFilterOptions = useMemo( + () => buildCostGroupFilterOptions(projectDevices), + [projectDevices] + ); + const effectiveProjectDeviceFilters = useMemo( + () => ({ + ...projectDeviceFilters, + costGroups: retainAvailableFilterValues( + projectDeviceFilters.costGroups, + costGroupFilterOptions + ), + }), + [costGroupFilterOptions, projectDeviceFilters] + ); + const visibleProjectDevices = useMemo( + () => + filterProjectDevices( + projectDevices, + effectiveProjectDeviceFilters, + circuitGroupCategoryLabels + ), + [effectiveProjectDeviceFilters, projectDevices] + ); const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]); + const floorFilterOptions = useMemo( + () => buildFloorFilterOptions(floors), + [floors] + ); + const supplyTypeFilterOptions = useMemo( + () => + buildSupplyTypeFilterOptions( + distributionBoardSupplyTypes, + project?.enabledDistributionBoardSupplyTypes ?? [], + boards, + distributionBoardSupplyTypeLabels + ), + [boards, project?.enabledDistributionBoardSupplyTypes] + ); + const effectiveBoardFilters = useMemo( + () => ({ + ...boardFilters, + floorIds: retainAvailableFilterValues( + boardFilters.floorIds, + floorFilterOptions + ), + supplyTypes: retainAvailableFilterValues( + boardFilters.supplyTypes, + supplyTypeFilterOptions + ), + }), + [boardFilters, floorFilterOptions, supplyTypeFilterOptions] + ); + const visibleBoards = useMemo( + () => + filterDistributionBoards( + boards, + effectiveBoardFilters, + new Map(floors.map((floor) => [floor.id, floor.name])) + ), + [boards, effectiveBoardFilters, floors] + ); const circuitListByBoardId = useMemo( () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), [circuitLists] @@ -363,6 +476,11 @@ export default function ProjectDetailPage() { if (!projectId || !project) { return; } + const device = projectDevices.find((item) => item.id === projectDeviceId); + const deviceName = device?.displayName || device?.name || "Projektgerät"; + if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) { + return; + } setIsSaving(true); setError(null); try { @@ -850,22 +968,28 @@ export default function ProjectDetailPage() {
- + +
@@ -910,14 +1034,52 @@ export default function ProjectDetailPage() { Verteilungen {boardCount}
- +
+ +
+ setBoardFilters(emptyDistributionBoardFilters)} + storageKey={boardFilterCollapsedStorageKey} + totalCount={boards.length} + visibleCount={visibleBoards.length} + > + + setBoardFilters((current) => ({ ...current, query })) + } + placeholder="Verteilungen durchsuchen …" + value={boardFilters.query} + /> + + setBoardFilters((current) => ({ ...current, floorIds })) + } + options={floorFilterOptions} + selected={effectiveBoardFilters.floorIds} + /> + + setBoardFilters((current) => ({ ...current, supplyTypes })) + } + options={supplyTypeFilterOptions} + selected={effectiveBoardFilters.supplyTypes} + /> +
@@ -930,7 +1092,7 @@ export default function ProjectDetailPage() { - {boards.map((board) => { + {visibleBoards.map((board) => { const circuitList = circuitListByBoardId.get(board.id); return ( @@ -957,22 +1119,21 @@ export default function ProjectDetailPage() { )}
-
- + /> {circuitList ? ( - - Stromkreisliste öffnen - + icon={ListTree} + label="Stromkreisliste öffnen" + showLabel + variant="primary" + /> ) : ( Keine Stromkreisliste vorhanden @@ -992,6 +1153,11 @@ export default function ProjectDetailPage() { ) : null}
+ {boards.length > 0 && visibleBoards.length === 0 ? ( +

+ Keine Verteilungen entsprechen den Filtern. +

+ ) : null}
@@ -1085,24 +1251,58 @@ export default function ProjectDetailPage() { Projektgeräte {projectDeviceCount} -
- setProjectDeviceQuery(event.target.value)} - placeholder="Geräte durchsuchen" - type="search" - value={projectDeviceQuery} - /> - + showLabel + variant="primary" + />
+ setProjectDeviceFilters(emptyProjectDeviceFilters)} + storageKey={projectDeviceFilterCollapsedStorageKey} + totalCount={projectDevices.length} + visibleCount={visibleProjectDevices.length} + > + + setProjectDeviceFilters((current) => ({ ...current, query })) + } + placeholder="Gerät, Anschlussart, KG …" + value={projectDeviceFilters.query} + /> + + setProjectDeviceFilters((current) => ({ ...current, categories })) + } + options={projectDeviceCategoryFilterOptions} + selected={effectiveProjectDeviceFilters.categories} + /> + + setProjectDeviceFilters((current) => ({ ...current, phaseTypes })) + } + options={projectDevicePhaseTypeFilterOptions} + selected={effectiveProjectDeviceFilters.phaseTypes} + /> + + setProjectDeviceFilters((current) => ({ ...current, costGroups })) + } + options={costGroupFilterOptions} + selected={effectiveProjectDeviceFilters.costGroups} + /> +
@@ -1121,11 +1321,13 @@ export default function ProjectDetailPage() { {device.displayName}
{device.name} - {device.category ? ` · ${device.category}` : ""} + {device.category + ? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}` + : ""}
@@ -1182,7 +1377,7 @@ export default function ProjectDetailPage() { @@ -1199,9 +1394,13 @@ export default function ProjectDetailPage() { Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.

- +
+ setSyncPreview(null)} + /> +
@@ -1280,23 +1479,23 @@ export default function ProjectDetailPage() {
- {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"} + {projectDevicePhaseTypeLabels[device.phaseType]} {device.connectionKind ? (
{device.connectionKind} @@ -1141,39 +1343,32 @@ export default function ProjectDetailPage() {
{device.costGroup ?? "-"} -
- - - - + icon={Trash2} + label="Löschen" + onClick={() => void handleDeleteProjectDevice(device.id)} + variant="danger" + />
{projectDevices.length - ? "Keine Projektgeräte entsprechen der Suche." + ? "Keine Projektgeräte entsprechen den Filtern." : "Noch keine Projektgeräte vorhanden."}
-
- - + icon={Unlink} + label="Auswahl trennen" + onClick={() => void handleDisconnectProjectDeviceRows()} + showLabel + variant="danger" + />
) : null} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..eb7c052 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,6 +1,30 @@ "use client"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowDown, + ArrowDown01, + ArrowUp, + Check, + Columns3, + FilterX, + FolderPlus, + Funnel, + ListOrdered, + ListPlus, + ListX, + PackagePlus, + PanelRightClose, + PanelRightOpen, + Pencil, + Plus, + Redo2, + ShieldPlus, + TableProperties, + Trash2, + Undo2, +} from "lucide-react"; +import { IconButton, IconButtonSeparator } from "./icon-button"; import { inferProjectDeviceSectionKey, isProjectDevicePlacementValid, @@ -76,6 +100,7 @@ import { type MutableDistributionBoardComponentRole, } from "../utils/distribution-board-component-editing"; import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; +import { createClientId } from "../utils/client-id"; import type { CellKey, ColumnDef, @@ -135,6 +160,7 @@ import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; +import { GzfPickerModal } from "./gzf-picker-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; import { @@ -246,6 +272,24 @@ function createValuesFromEditPatch( ); } +/** + * Header labels break only between words: hyphens become non-breaking and a + * trailing unit such as "[kW]" is always shown on its own second line, so + * header heights stay uniform. + */ +function renderColumnHeaderLabel(label: string) { + const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label); + const name = (match ? match[1] : label).replace(/-/g, "‑"); + return match ? ( + <> + {name} + {match[2]} + + ) : ( + name + ); +} + export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) { /* Circuit-tree editor invariant overview: @@ -276,6 +320,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str useState(null); const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = useState(null); + // GZF cell the AMEV picker writes to; null while the picker is closed. + const [gzfPickerCell, setGzfPickerCell] = useState(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); const [projectDevices, setProjectDevices] = useState([]); @@ -977,7 +1023,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str defaultSortOrder: number ) { return buildCircuitDeviceRowInsertSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitId, values, defaultSortOrder, @@ -1001,7 +1047,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ), data ); - const circuitId = crypto.randomUUID(); + const circuitId = createClientId(); const deviceRows = deviceRowValues.map((row, index) => createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) ); @@ -1169,7 +1215,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str (section) => section.id === intent.sectionId )?.components ?? []; const snapshot = buildDistributionBoardComponentSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitListId, role: intent.role, sectionId: intent.sectionId, @@ -1234,7 +1280,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str projectId, getExpectedProjectRevision(), buildNewCircuitGroupSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitListId, category: values.category, displayName: values.displayName, @@ -1829,22 +1875,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } // Commits the active editor input through command history so edits remain undoable. + // `target` commits a value for a cell without an open inline editor + // (AMEV GZF picker); the regular edit path passes no target. async function commitEdit( direction: SaveDirection = "stay", - draftOverride?: string + draftOverride?: string, + target?: SelectedCell ) { - if (!editingCell) { + const session = target ?? editingCell; + if (!session) { return; } - const row = findRow(editingCell.rowKey); - const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); + const row = findRow(session.rowKey); + const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey); if (!row || !cell || !cell.editable) { setEditingCell(null); return; } - const draft = draftOverride ?? editingCell.draft; + const draft = draftOverride ?? editingCell?.draft ?? ""; if ( - editingCell.cellKey === "equipmentIdentifier" && + session.cellKey === "equipmentIdentifier" && row.circuit && hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft) ) { @@ -1852,7 +1902,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; + let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey }; const nextSelectionIntent = () => { let selected = targetSelection; if (direction !== "stay") { @@ -1875,7 +1925,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str redo: async () => { const selection = await createFromPlaceholder( row.sectionId, - editingCell.cellKey, + session.cellKey, draft ); targetSelection = selection; @@ -1892,7 +1942,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const command: HistoryCommand = { label: "Stromkreisfeld bearbeiten", redo: async () => { - await patchCircuit(row.circuit!.id, editingCell.cellKey, next); + await patchCircuit(row.circuit!.id, session.cellKey, next); return nextSelectionIntent(); }, }; @@ -1906,7 +1956,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const command: HistoryCommand = { label: "Gerätefeld bearbeiten", redo: async () => { - await patchDeviceRow(row.device!.id, editingCell.cellKey, next); + await patchDeviceRow(row.device!.id, session.cellKey, next); return nextSelectionIntent(); }, }; @@ -1921,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str label: "Gerätezeile im Reservestromkreis erstellen", redo: async () => { const editValues = createValuesFromEditPatch( - buildDeviceRowEditPatch(editingCell.cellKey, next) + buildDeviceRowEditPatch(session.cellKey, next) ); const snapshot = createDeviceRowSnapshot( row.circuit!.id, @@ -1941,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str snapshot, "Gerätezeile im Reservestromkreis erstellen" ); - targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; + targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey }; return nextSelectionIntent(); }, }; @@ -1950,6 +2000,45 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell { + return Boolean( + target && + target.cellKey === "simultaneityFactor" && + findCell(target.rowKey, target.cellKey)?.editable + ); + } + + // Opens the AMEV simultaneity-factor table for one GZF cell. A running + // inline edit is discarded because the picked value replaces it. + function openGzfPicker(target: SelectedCell) { + if (isSaving || historyBusy || !isGzfPickerCell(target)) { + return; + } + setEditingCell(null); + setSelectedCell(target); + setGzfPickerCell(target); + } + + function closeGzfPicker() { + setGzfPickerCell(null); + focusGridWithoutScroll(); + } + + // Commits the picked mean value like a typed edit (undoable command). + function handleGzfPicked(value: number) { + const target = gzfPickerCell; + setGzfPickerCell(null); + if (!target) { + return; + } + // parseNumeric accepts only the German decimal comma. + const draft = value.toLocaleString("de-DE", { + maximumFractionDigits: 2, + useGrouping: false, + }); + void commitEdit("stay", draft, target); + } + // Cancels current edit session and restores grid keyboard focus. function cancelEdit() { if (!editingCell) { @@ -2893,6 +2982,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // - printable key starts overwrite edit (type-to-edit) // - Arrow/Tab move selectedCell when not editing function handleContainerKeyDown(event: KeyboardEvent) { + // The picker modal sits outside the grid; keep grid keys from acting behind it. + if (gzfPickerCell) { + if (event.key === "Escape") { + event.preventDefault(); + closeGzfPicker(); + } + return; + } if (editingCell) { if (event.key === "Tab") { event.preventDefault(); @@ -2960,6 +3057,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str moveHorizontal(-1); return; } + if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) { + event.preventDefault(); + openGzfPicker(selectedCell); + return; + } if (event.key === "ArrowDown") { event.preventDefault(); moveVertical(1); @@ -2995,32 +3097,37 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
- - - - - {renderColumnSettingsMenu("col-empty")} -
+ icon={Redo2} + label="Wiederholen" + onClick={() => void handleRedo()} + showLabel + /> + + + + {renderColumnSettingsMenu("col-empty")} +
{renderActiveViewSummary()} {renderDistributionPowerSummary()} {error ? ( @@ -3122,6 +3229,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitGroup} /> ) : null} + {gzfPickerCell ? ( + + ) : null} {protectionEditorCircuit ? ( ) : null}
- - + /> + {isSortedView ? ( - + /> ) : null} - - - - - + showLabel + /> + + setIsProjectDeviceDrawerOpen((current) => !current) + } + showLabel + /> {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} @@ -3405,7 +3523,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }} > - {column.label} + + {renderColumnHeaderLabel(column.label)} + {sortState?.key === column.key ? ( {sortState.direction === "asc" ? "↑" : "↓"} @@ -3416,6 +3536,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`} onClick={() => toggleColumnFilterMenu(column.key)} + aria-label={`${getFullColumnLabel(column)} filtern`} aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0} title={ (columnFilters[column.key]?.length ?? 0) > 0 @@ -3423,7 +3544,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str : `${getFullColumnLabel(column)} filtern` } > - Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""} +
- - - - - - - - - + tabIndex={-1} + title={ + hasActiveSortOrFilter + ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." + : "Stromkreise dieser Gruppe neu nummerieren" + } + /> + + section.category + ? void handleRenumberCircuitGroups( + section.category + ) + : undefined + } + tabIndex={-1} + title={ + hasActiveSortOrFilter + ? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen." + : "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren" + } + /> + + + void handleDeleteCircuitGroup(section) + } + tabIndex={-1} + title={ + canDeleteCircuitGroup(section) + ? "Leere Stromkreisgruppe entfernen" + : "Befüllte Stromkreisgruppe entfernen (mit Warnung)" + } + variant="danger" + />
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( @@ -4423,11 +4550,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } else if (event.key === "Tab") { event.preventDefault(); void commitEdit(event.shiftKey ? "prev" : "next"); + } else if ( + event.altKey && + event.key === "ArrowDown" && + column.key === "simultaneityFactor" + ) { + event.preventDefault(); + openGzfPicker({ rowKey: row.rowKey, cellKey: column.key }); } }} onBlur={() => { requestAnimationFrame(() => { const active = document.activeElement as HTMLElement | null; + if (active?.closest('[role="dialog"][aria-modal="true"]')) { + return; + } if (!active || !containerRef.current?.contains(active)) { setEditingCell(null); focusGridWithoutScroll(); @@ -4435,6 +4572,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }} /> + ) : column.key === "simultaneityFactor" && + cell.editable && + isSelected ? ( + <> + + {formatCellValue(cell.value, column.key)} + ) : ( formatCellValue(cell.value, column.key) )} @@ -4467,28 +4625,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str > {row.circuit && row.rowType !== "deviceRow" ? ( <> - - + tabIndex={-1} + variant="danger" + /> ) : null} {row.device ? ( - + void handleDeleteDevice(row.device!.id)} + tabIndex={-1} + variant="danger" + /> ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( diff --git a/src/frontend/components/filter-panel.tsx b/src/frontend/components/filter-panel.tsx new file mode 100644 index 0000000..afa1fde --- /dev/null +++ b/src/frontend/components/filter-panel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { ChevronDown, ChevronRight, FilterX } from "lucide-react"; +import React, { useEffect, useId, useState, type ReactNode } from "react"; +import { IconButton } from "./icon-button"; + +function readCollapsed(storageKey: string) { + try { + return window.localStorage.getItem(storageKey) === "1"; + } catch { + return false; + } +} + +function writeCollapsed(storageKey: string, isCollapsed: boolean) { + try { + window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0"); + } catch { + // Storage unavailable (private mode, blocked): keep the in-memory state. + } +} + +/** + * Collapsible filter area inside a card (PM app style): a small header row + * with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the + * "x von y" count; below it the wrapping filter bar with a "Zurücksetzen" + * button while a filter is active. Only the bar collapses, never the card. + * The collapsed state is remembered per `storageKey` (default: expanded). + */ +export function CollapsibleFilterPanel({ + storageKey, + isFilterActive, + visibleCount, + totalCount, + onReset, + children, +}: { + storageKey: string; + isFilterActive: boolean; + visibleCount: number; + totalCount: number; + onReset: () => void; + children: ReactNode; +}) { + const [isCollapsed, setIsCollapsed] = useState(false); + const barId = useId(); + + useEffect(() => { + setIsCollapsed(readCollapsed(storageKey)); + }, [storageKey]); + + function toggleCollapsed() { + const next = !isCollapsed; + setIsCollapsed(next); + writeCollapsed(storageKey, next); + } + + return ( +
+
+ + Filter + {isCollapsed && isFilterActive ? ( + Filter aktiv + ) : null} + + {visibleCount} von {totalCount} + +
+ {isCollapsed ? null : ( +
+ {children} + {isFilterActive ? ( + + ) : null} +
+ )} +
+ ); +} + +/** Search field for a filter bar. */ +export function FilterSearchField({ + label, + placeholder, + value, + onChange, +}: { + label: string; + placeholder: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/src/frontend/components/gzf-picker-modal.tsx b/src/frontend/components/gzf-picker-modal.tsx index 5f118a6..c09ef4f 100644 --- a/src/frontend/components/gzf-picker-modal.tsx +++ b/src/frontend/components/gzf-picker-modal.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useId, useRef } from "react"; import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf"; interface GzfPickerModalProps { @@ -12,14 +13,50 @@ function formatFactor(value: number) { } export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + // Wait for the inline editor's blur callback before taking keyboard focus. + const frame = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, []); + return ( <> -
+
{ + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } else if (event.key === "Tab") { + const buttons = dialogRef.current?.querySelectorAll("button:not(:disabled)"); + const first = buttons?.[0]; + const last = buttons?.[buttons.length - 1]; + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + }} + >
-

Gleichzeitigkeitsfaktor nach AMEV

+

Gleichzeitigkeitsfaktor nach AMEV

Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen

diff --git a/src/frontend/components/icon-button.tsx b/src/frontend/components/icon-button.tsx new file mode 100644 index 0000000..4973029 --- /dev/null +++ b/src/frontend/components/icon-button.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import React, { type ReactNode } from "react"; + +/** + * Compact editor button with a lucide icon. `label` is always the accessible + * name; it is also the tooltip unless a more specific `title` is given. + * With `showLabel` the label (or `children`) is rendered next to the icon. + */ +export function IconButton({ + icon: Icon, + label, + title, + onClick, + disabled, + variant = "default", + showLabel = false, + children, + className, + tabIndex, + ariaExpanded, + ariaControls, +}: { + icon: LucideIcon; + label: string; + title?: string; + onClick: () => void; + disabled?: boolean; + variant?: "default" | "danger" | "primary"; + showLabel?: boolean; + children?: ReactNode; + className?: string; + tabIndex?: number; + ariaExpanded?: boolean; + ariaControls?: string; +}) { + return ( + + ); +} + +/** Thin vertical divider between groups of icon buttons. */ +export function IconButtonSeparator() { + return
{error ? ( @@ -381,14 +389,15 @@ export function ProjectVersionHistory({ {formatDateTime(snapshot.createdAtIso)} - +
+ setRestoreCandidate(snapshot)} + variant="danger" + /> +
))} diff --git a/src/frontend/utils/client-id.ts b/src/frontend/utils/client-id.ts new file mode 100644 index 0000000..f3804d5 --- /dev/null +++ b/src/frontend/utils/client-id.ts @@ -0,0 +1,27 @@ +/** + * Creates a random RFC 4122 version 4 UUID for new entities built in the + * browser. + * + * crypto.randomUUID() exists only in secure contexts (HTTPS or localhost). + * The app is also opened over plain HTTP from other machines in the LAN, where + * it is undefined, so fall back to crypto.getRandomValues(), which is + * available in every context. + */ +export function createClientId( + source: Pick & Partial> = crypto +): string { + if (typeof source.randomUUID === "function") { + return source.randomUUID(); + } + const bytes = source.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join("-"); +} diff --git a/src/frontend/utils/overview-filters.ts b/src/frontend/utils/overview-filters.ts new file mode 100644 index 0000000..8757597 --- /dev/null +++ b/src/frontend/utils/overview-filters.ts @@ -0,0 +1,249 @@ +import type { + DistributionBoardDto, + FloorDto, + ProjectDeviceDto, +} from "../types"; + +/** + * Pure filter logic for the project overview cards (Verteilungen, + * Projektgeräte). Every multi-select filter uses the PM semantics: an empty + * selection means "Alle", otherwise a record must match one selected value. + * Records without a value are addressed through the dedicated "none" ids. + */ + +export interface FilterOption { + id: string; + label: string; +} + +/** Option id for boards without floor ("Ohne Etage"). */ +export const withoutFloorFilterId = "__without_floor__"; +/** Option id for boards without supply type ("Nicht festgelegt"). */ +export const withoutSupplyTypeFilterId = "__without_supply_type__"; +/** Option id for project devices without cost group ("Ohne Kostengruppe"). */ +export const withoutCostGroupFilterId = "__without_cost_group__"; + +export interface DistributionBoardFilters { + query: string; + floorIds: string[]; + supplyTypes: string[]; +} + +export interface ProjectDeviceFilters { + query: string; + categories: string[]; + phaseTypes: string[]; + costGroups: string[]; +} + +export const emptyDistributionBoardFilters: DistributionBoardFilters = { + query: "", + floorIds: [], + supplyTypes: [], +}; + +export const emptyProjectDeviceFilters: ProjectDeviceFilters = { + query: "", + categories: [], + phaseTypes: [], + costGroups: [], +}; + +export function normalizeFilterQuery(query: string) { + return query.trim().toLocaleLowerCase("de"); +} + +function matchesQuery( + normalizedQuery: string, + values: readonly (string | null | undefined)[] +) { + return ( + !normalizedQuery || + values.some((value) => + value?.toLocaleLowerCase("de").includes(normalizedQuery) + ) + ); +} + +function matchesSelection(selected: readonly string[], value: string) { + return selected.length === 0 || selected.includes(value); +} + +/** Adds or removes one value from a multi-select selection. */ +export function toggleFilterValue(selected: readonly string[], value: string) { + return selected.includes(value) + ? selected.filter((entry) => entry !== value) + : [...selected, value]; +} + +/** + * Drops selected values that are no longer offered (e.g. a deleted floor), so + * no invisible selection keeps hiding records. + */ +export function retainAvailableFilterValues( + selected: readonly string[], + options: readonly FilterOption[] +) { + const available = new Set(options.map((option) => option.id)); + return selected.filter((value) => available.has(value)); +} + +export function isDistributionBoardFilterActive( + filters: DistributionBoardFilters +) { + return ( + normalizeFilterQuery(filters.query) !== "" || + filters.floorIds.length > 0 || + filters.supplyTypes.length > 0 + ); +} + +export function isProjectDeviceFilterActive(filters: ProjectDeviceFilters) { + return ( + normalizeFilterQuery(filters.query) !== "" || + filters.categories.length > 0 || + filters.phaseTypes.length > 0 || + filters.costGroups.length > 0 + ); +} + +/** Floors in their configured order, followed by "Ohne Etage". */ +export function buildFloorFilterOptions( + floors: readonly FloorDto[] +): FilterOption[] { + return [ + ...[...floors] + .sort( + (left, right) => + left.sortOrder - right.sortOrder || + left.name.localeCompare(right.name, "de", { numeric: true }) + ) + .map((floor) => ({ id: floor.id, label: floor.name })), + { id: withoutFloorFilterId, label: "Ohne Etage" }, + ]; +} + +/** + * Supply types in catalog order: every enabled or used type, plus + * "Nicht festgelegt" when a board has no supply type. + */ +export function buildSupplyTypeFilterOptions( + catalog: readonly string[], + enabledSupplyTypes: readonly string[], + boards: readonly Pick[], + labels: Readonly> +): FilterOption[] { + const offered = new Set(enabledSupplyTypes); + let hasBoardWithoutSupplyType = false; + for (const board of boards) { + if (board.supplyType) { + offered.add(board.supplyType); + } else { + hasBoardWithoutSupplyType = true; + } + } + const options: FilterOption[] = catalog + .filter((supplyType) => offered.has(supplyType)) + .map((supplyType) => ({ + id: supplyType, + label: labels[supplyType] ?? supplyType, + })); + if (hasBoardWithoutSupplyType) { + options.push({ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" }); + } + return options; +} + +/** + * Board number and location come with the distribution-board info card + * (feature/print-view). They are optional here so the filter works with and + * without those fields and searches them as soon as the DTO carries them. + */ +export function filterDistributionBoards< + Board extends Pick & { + boardNumber?: string | null; + location?: string | null; + }, +>( + boards: readonly Board[], + filters: DistributionBoardFilters, + floorNameById: ReadonlyMap +): Board[] { + const query = normalizeFilterQuery(filters.query); + return boards.filter( + (board) => + matchesQuery(query, [ + board.name, + board.boardNumber, + board.location, + board.floorId ? floorNameById.get(board.floorId) : undefined, + ]) && + matchesSelection( + filters.floorIds, + board.floorId ?? withoutFloorFilterId + ) && + matchesSelection( + filters.supplyTypes, + board.supplyType ?? withoutSupplyTypeFilterId + ) + ); +} + +function getCostGroupFilterValue(costGroup: string | null) { + const trimmed = costGroup?.trim() ?? ""; + return trimmed || withoutCostGroupFilterId; +} + +/** Distinct cost groups in natural order, followed by "Ohne Kostengruppe". */ +export function buildCostGroupFilterOptions( + devices: readonly Pick[] +): FilterOption[] { + const costGroups = new Set(); + for (const device of devices) { + const value = getCostGroupFilterValue(device.costGroup); + if (value !== withoutCostGroupFilterId) { + costGroups.add(value); + } + } + return [ + ...[...costGroups] + .sort((left, right) => left.localeCompare(right, "de", { numeric: true })) + .map((costGroup) => ({ id: costGroup, label: costGroup })), + { id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" }, + ]; +} + +export function filterProjectDevices< + Device extends Pick< + ProjectDeviceDto, + | "name" + | "displayName" + | "category" + | "phaseType" + | "connectionKind" + | "costGroup" + >, +>( + devices: readonly Device[], + filters: ProjectDeviceFilters, + categoryLabels: Readonly> = {} +): Device[] { + const query = normalizeFilterQuery(filters.query); + return devices.filter( + (device) => + matchesQuery(query, [ + device.name, + device.displayName, + device.category, + categoryLabels[device.category], + device.connectionKind, + device.costGroup, + ]) && + matchesSelection(filters.categories, device.category) && + matchesSelection(filters.phaseTypes, device.phaseType) && + matchesSelection( + filters.costGroups, + getCostGroupFilterValue(device.costGroup) + ) + ); +} diff --git a/tests/client-id.test.ts b/tests/client-id.test.ts new file mode 100644 index 0000000..95baa60 --- /dev/null +++ b/tests/client-id.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { describe, it } from "node:test"; +import { z } from "zod"; +import { createClientId } from "../src/frontend/utils/client-id.js"; + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("client id", () => { + it("uses crypto.randomUUID when the context provides it", () => { + const id = createClientId({ + getRandomValues: () => { + throw new Error("fallback must not be used"); + }, + randomUUID: () => "11111111-2222-4333-8444-555555555555", + }); + assert.equal(id, "11111111-2222-4333-8444-555555555555"); + }); + + it("falls back to getRandomValues outside a secure context", () => { + const insecure = { + getRandomValues: webcrypto.getRandomValues.bind(webcrypto), + } as Pick; + const ids = new Set(); + for (let index = 0; index < 200; index += 1) { + const id = createClientId(insecure); + assert.match(id, uuidV4); + assert.equal(z.string().uuid().safeParse(id).success, true); + ids.add(id); + } + assert.equal(ids.size, 200); + }); + + it("sets version and variant bits regardless of the random bytes", () => { + const allOnes = { + getRandomValues: (array: T) => { + if (array instanceof Uint8Array) { + array.fill(0xff); + } + return array; + }, + } as Pick; + assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff"); + }); +}); diff --git a/tests/overview-filters.test.ts b/tests/overview-filters.test.ts new file mode 100644 index 0000000..84c5807 --- /dev/null +++ b/tests/overview-filters.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildCostGroupFilterOptions, + buildFloorFilterOptions, + buildSupplyTypeFilterOptions, + emptyDistributionBoardFilters, + emptyProjectDeviceFilters, + filterDistributionBoards, + filterProjectDevices, + isDistributionBoardFilterActive, + isProjectDeviceFilterActive, + retainAvailableFilterValues, + toggleFilterValue, + withoutCostGroupFilterId, + withoutFloorFilterId, + withoutSupplyTypeFilterId, +} from "../src/frontend/utils/overview-filters.js"; +import type { + DistributionBoardDto, + FloorDto, + ProjectDeviceDto, +} from "../src/frontend/types.js"; + +type TestBoard = Pick< + DistributionBoardDto, + "id" | "name" | "floorId" | "supplyType" +>; + +function board( + id: string, + name: string, + floorId: string | null, + supplyType: DistributionBoardDto["supplyType"] +): TestBoard { + return { id, name, floorId, supplyType }; +} + +function device( + id: string, + overrides: Partial +): ProjectDeviceDto { + return { + id, + projectId: "project-1", + name: `device-${id}`, + displayName: `Gerät ${id}`, + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: "single_phase", + quantity: 1, + powerPerUnit: 1, + simultaneityFactor: 1, + totalPower: 1, + cosPhi: null, + remark: null, + voltageV: null, + ...overrides, + }; +} + +const floors: FloorDto[] = [ + { id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 }, + { id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 }, +]; +const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name])); + +const boards = [ + board("b1", "UV-EG-01", "floor-eg", "AV"), + board("b2", "UV-OG-01", "floor-og", "SV"), + board("b3", "HV", null, "AV"), + board("b4", "SiBe-Zentrale", null, null), +]; + +describe("overview filters: distribution boards", () => { + it("returns every board without an active filter", () => { + assert.equal(isDistributionBoardFilterActive(emptyDistributionBoardFilters), false); + assert.deepEqual( + filterDistributionBoards(boards, emptyDistributionBoardFilters, floorNameById), + boards + ); + }); + + it("also searches board number and location when the boards carry them", () => { + const withInfo = [ + { ...boards[0]!, boardNumber: "UV 0.1", location: "Raum 003 Technik" }, + { ...boards[1]!, boardNumber: null, location: null }, + ]; + assert.deepEqual( + filterDistributionBoards( + withInfo, + { ...emptyDistributionBoardFilters, query: "uv 0.1" }, + floorNameById + ).map((entry) => entry.id), + [withInfo[0]!.id] + ); + assert.deepEqual( + filterDistributionBoards( + withInfo, + { ...emptyDistributionBoardFilters, query: "technik" }, + floorNameById + ).map((entry) => entry.id), + [withInfo[0]!.id] + ); + }); + + it("searches board and floor names case-insensitively and ignores blank queries", () => { + const byFloorName = filterDistributionBoards( + boards, + { ...emptyDistributionBoardFilters, query: " og " }, + floorNameById + ); + assert.deepEqual(byFloorName.map((entry) => entry.id), ["b2"]); + const byBoardName = filterDistributionBoards( + boards, + { ...emptyDistributionBoardFilters, query: "sibe" }, + floorNameById + ); + assert.deepEqual(byBoardName.map((entry) => entry.id), ["b4"]); + assert.equal( + isDistributionBoardFilterActive({ ...emptyDistributionBoardFilters, query: " " }), + false + ); + }); + + it("filters floors with the dedicated option for boards without floor", () => { + const result = filterDistributionBoards( + boards, + { ...emptyDistributionBoardFilters, floorIds: ["floor-eg", withoutFloorFilterId] }, + floorNameById + ); + assert.deepEqual(result.map((entry) => entry.id), ["b1", "b3", "b4"]); + }); + + it("combines supply type with the other criteria", () => { + const filters = { + query: "", + floorIds: [withoutFloorFilterId], + supplyTypes: ["AV"], + }; + assert.equal(isDistributionBoardFilterActive(filters), true); + assert.deepEqual( + filterDistributionBoards(boards, filters, floorNameById).map((entry) => entry.id), + ["b3"] + ); + assert.deepEqual( + filterDistributionBoards( + boards, + { ...emptyDistributionBoardFilters, supplyTypes: [withoutSupplyTypeFilterId] }, + floorNameById + ).map((entry) => entry.id), + ["b4"] + ); + }); + + it("offers floors in configured order followed by 'Ohne Etage'", () => { + assert.deepEqual(buildFloorFilterOptions(floors), [ + { id: "floor-eg", label: "EG" }, + { id: "floor-og", label: "1. OG" }, + { id: withoutFloorFilterId, label: "Ohne Etage" }, + ]); + }); + + it("offers enabled and used supply types in catalog order", () => { + const options = buildSupplyTypeFilterOptions( + ["AV", "SV", "EV", "USV"], + ["USV", "AV"], + boards, + { AV: "AV – Allgemein", SV: "SV – Sicherheit" } + ); + assert.deepEqual(options, [ + { id: "AV", label: "AV – Allgemein" }, + { id: "SV", label: "SV – Sicherheit" }, + { id: "USV", label: "USV" }, + { id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" }, + ]); + }); +}); + +describe("overview filters: project devices", () => { + const devices = [ + device("d1", { + displayName: "Steckdose Büro", + costGroup: "444", + connectionKind: "Steckdose", + }), + device("d2", { + displayName: "Durchlauferhitzer", + phaseType: "three_phase", + category: "three_phase", + costGroup: " 445 ", + }), + device("d3", { + displayName: "Downlight", + category: "lighting", + costGroup: " ", + }), + device("d4", { displayName: "Kaffeemaschine", costGroup: null }), + ]; + + it("searches names, connection kind, cost group and the German category label", () => { + assert.deepEqual( + filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "STECK" }).map( + (entry) => entry.id + ), + ["d1"] + ); + assert.deepEqual( + filterProjectDevices( + devices, + { ...emptyProjectDeviceFilters, query: "beleucht" }, + { lighting: "Beleuchtung" } + ).map((entry) => entry.id), + ["d3"] + ); + }); + + it("filters category, phase type and cost group together", () => { + const filters = { + query: "", + categories: ["single_phase", "three_phase"], + phaseTypes: ["three_phase"], + costGroups: ["445"], + }; + assert.equal(isProjectDeviceFilterActive(filters), true); + assert.deepEqual( + filterProjectDevices(devices, filters).map((entry) => entry.id), + ["d2"] + ); + }); + + it("treats blank cost groups as 'Ohne Kostengruppe'", () => { + assert.deepEqual( + filterProjectDevices(devices, { + ...emptyProjectDeviceFilters, + costGroups: [withoutCostGroupFilterId], + }).map((entry) => entry.id), + ["d3", "d4"] + ); + assert.deepEqual(buildCostGroupFilterOptions(devices), [ + { id: "444", label: "444" }, + { id: "445", label: "445" }, + { id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" }, + ]); + }); + + it("returns an empty list when the filters hide every device", () => { + assert.deepEqual( + filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "gibt es nicht" }), + [] + ); + assert.equal(isProjectDeviceFilterActive(emptyProjectDeviceFilters), false); + }); +}); + +describe("overview filters: selection helpers", () => { + it("toggles values without mutating the selection", () => { + const selected = ["a"]; + assert.deepEqual(toggleFilterValue(selected, "b"), ["a", "b"]); + assert.deepEqual(toggleFilterValue(selected, "a"), []); + assert.deepEqual(selected, ["a"]); + }); + + it("drops selected values that are no longer offered", () => { + assert.deepEqual( + retainAvailableFilterValues(["floor-eg", "deleted-floor"], buildFloorFilterOptions(floors)), + ["floor-eg"] + ); + }); +});