Compare commits
14 commits
2e3e85a8e5
...
3ffa50be4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ffa50be4c | |||
| 2e223be8b4 | |||
| 641513c2ef | |||
| 203757b92c | |||
| 0a17da2b8e | |||
|
|
68b9ee850b | ||
|
|
837b29c7c5 | ||
|
|
ead01fe5bd | ||
|
|
51cbab1750 | ||
|
|
79fcb75ffb | ||
|
|
ced4917a86 | ||
|
|
db4a6c3a2e | ||
|
|
d95f0bf5c0 | ||
|
|
e394ff93f4 |
20 changed files with 2301 additions and 412 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -164,19 +164,53 @@ Keyboard behavior:
|
||||||
- Escape cancels
|
- Escape cancels
|
||||||
- Tab / Shift+Tab navigates editable cells
|
- Tab / Shift+Tab navigates editable cells
|
||||||
- Arrow keys navigate cells when not editing
|
- 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.
|
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 / Styling Rule
|
||||||
|
|
||||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
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.
|
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.
|
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.
|
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
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||||
|
|
|
||||||
13
README.md
13
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,
|
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
||||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||||
implementiert.
|
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
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -25,6 +27,9 @@ implementiert.
|
||||||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
- 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
|
## Technik
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# Circuit List Editor Interactions
|
# 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
|
## Editing Model
|
||||||
|
|
||||||
Inline cells are static text by default. A cell enters edit mode by:
|
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
|
- 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
|
- 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
|
- `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
|
- `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.
|
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.
|
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 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.
|
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
|
## 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.
|
- Per-column filtering works on normalized displayed values.
|
||||||
- Sorting is view-level first and treats circuits as blocks.
|
- Sorting is view-level first and treats circuits as blocks.
|
||||||
- Multi-device circuits are not split during sort.
|
- Multi-device circuits are not split during sort.
|
||||||
- Sorting alone does not persist order.
|
- 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
|
||||||
|
|
||||||
`Apply sorted order` persists current sorted block order to backend by section.
|
`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
|
renumbering uses a collision-safe persistent project command and one
|
||||||
project-history step.
|
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:
|
Covered operations include:
|
||||||
|
|
||||||
- insert/delete circuit
|
- insert/delete circuit
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,18 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
||||||
data/leistungsbilanz.db (SQLite)
|
data/leistungsbilanz.db (SQLite)
|
||||||
```
|
```
|
||||||
|
|
||||||
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
||||||
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
||||||
liegt über einen Host-Mount außerhalb des Containers.
|
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
|
## 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.
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
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
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
|
|
|
||||||
|
|
@ -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
|
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||||
Entwicklungsrechner.
|
Entwicklungsrechner.
|
||||||
|
|
||||||
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
||||||
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
||||||
erreichbar gemacht werden.
|
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
||||||
|
deshalb nicht öffentlich erreichbar gemacht werden.
|
||||||
|
|
||||||
## Topologie
|
## 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
|
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.
|
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
|
### Speichergrenzen
|
||||||
|
|
||||||
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
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
|
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||||
Mehrbenutzerbetrieb erforderlich ist
|
Mehrbenutzerbetrieb erforderlich ist
|
||||||
|
|
||||||
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
||||||
lokalen Entwicklungsstart in der README.
|
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
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
|
`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
|
ö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
|
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
|
||||||
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
||||||
|
|
|
||||||
|
|
@ -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
|
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
||||||
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
|
angelegt. Das Frontend ist unter <http://localhost:3001> 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:
|
Für direkte Node.js-Entwicklung:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
docker compose ps
|
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.
|
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,263 @@ a {
|
||||||
font-weight: 600;
|
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-Kacheln ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.kpi-grid {
|
.kpi-grid {
|
||||||
|
|
@ -431,12 +688,29 @@ a.kpi:hover {
|
||||||
color: var(--color-accent-pale);
|
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 {
|
.card {
|
||||||
border-radius: var(--radius);
|
--lb-card-shadow:
|
||||||
border-top: 3px solid var(--color-primary);
|
0 4px 24px var(--shadow-soft),
|
||||||
box-shadow:
|
0 1px 0 rgba(255, 255, 255, 0.7) inset;
|
||||||
0 1px 2px var(--shadow-soft),
|
--bs-box-shadow-sm: var(--lb-card-shadow);
|
||||||
0 10px 24px -14px var(--shadow-strong);
|
--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 {
|
.card-header {
|
||||||
|
|
@ -487,6 +761,54 @@ a.kpi:hover {
|
||||||
opacity: 0.45;
|
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 {
|
.editor-toolbar .project-device-drawer-toggle {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
background: var(--accent-blue);
|
background: var(--accent-blue);
|
||||||
|
|
@ -844,24 +1166,37 @@ a.kpi:hover {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One header row: label (sort) on the left, filter icon on the right. */
|
||||||
.tree-grid .header-cell {
|
.tree-grid .header-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-btn {
|
.tree-grid .header-filter-btn {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
width: 100%;
|
flex: none;
|
||||||
border: 1px solid var(--panel-border-strong);
|
align-items: center;
|
||||||
background: var(--panel-bg);
|
gap: 0.15rem;
|
||||||
color: var(--text-strong);
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.15rem 0.35rem;
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem 0.2rem;
|
||||||
white-space: nowrap;
|
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 {
|
.tree-grid .header-sort-btn {
|
||||||
|
|
@ -879,7 +1214,10 @@ a.kpi:hover {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.15;
|
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;
|
white-space: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -919,6 +1257,18 @@ a.kpi:hover {
|
||||||
font-size: 0.78rem;
|
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:hover span:first-child,
|
||||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
@ -935,6 +1285,11 @@ a.kpi:hover {
|
||||||
background: var(--accent-blue-soft-bg);
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .header-filter-count {
|
||||||
|
min-width: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-menu {
|
.tree-grid .header-filter-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 7;
|
z-index: 7;
|
||||||
|
|
@ -1153,7 +1508,20 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .section-actions {
|
.tree-grid .section-actions {
|
||||||
display: flex;
|
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 {
|
.tree-grid .section-actions button {
|
||||||
|
|
@ -1230,6 +1598,38 @@ a.kpi:hover {
|
||||||
outline-offset: -2px;
|
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 {
|
.tree-grid .section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
@ -1265,7 +1665,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .action-cell {
|
.tree-grid .action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .action-cell button {
|
.tree-grid .action-cell button {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||||
|
|
@ -10,10 +11,20 @@ export default function CircuitTreeEditPage() {
|
||||||
return (
|
return (
|
||||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Link
|
||||||
|
aria-label="Zurück zur Verteilerübersicht"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
href={`/projects/${params.projectId}`}
|
||||||
|
title="Zurück zur Verteilerübersicht"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={18} />
|
||||||
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||||
Zum Projekt
|
Zum Projekt
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,47 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
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 {
|
import {
|
||||||
copyGlobalDeviceToProject,
|
copyGlobalDeviceToProject,
|
||||||
copyDistributionBoard,
|
copyDistributionBoard,
|
||||||
|
|
@ -77,6 +118,29 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectDeviceCategoryFilterOptions: FilterOption[] =
|
||||||
|
circuitGroupCategories.map((category) => ({
|
||||||
|
id: category,
|
||||||
|
label: circuitGroupCategoryLabels[category],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projectDevicePhaseTypeLabels: Record<ProjectDeviceDto["phaseType"], string> = {
|
||||||
|
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() {
|
export default function ProjectDetailPage() {
|
||||||
const params = useParams<{ projectId: string }>();
|
const params = useParams<{ projectId: string }>();
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
|
|
@ -113,7 +177,11 @@ export default function ProjectDetailPage() {
|
||||||
useState(false);
|
useState(false);
|
||||||
const [editingProjectDevice, setEditingProjectDevice] =
|
const [editingProjectDevice, setEditingProjectDevice] =
|
||||||
useState<ProjectDeviceDto | null>(null);
|
useState<ProjectDeviceDto | null>(null);
|
||||||
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
|
||||||
|
emptyDistributionBoardFilters
|
||||||
|
);
|
||||||
|
const [projectDeviceFilters, setProjectDeviceFilters] =
|
||||||
|
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
|
||||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
|
|
@ -164,22 +232,67 @@ export default function ProjectDetailPage() {
|
||||||
const floorCount = useMemo(() => floors.length, [floors.length]);
|
const floorCount = useMemo(() => floors.length, [floors.length]);
|
||||||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||||||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||||||
const visibleProjectDevices = useMemo(() => {
|
const costGroupFilterOptions = useMemo(
|
||||||
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
() => buildCostGroupFilterOptions(projectDevices),
|
||||||
if (!query) {
|
[projectDevices]
|
||||||
return projectDevices;
|
);
|
||||||
}
|
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
|
||||||
return projectDevices.filter((device) =>
|
() => ({
|
||||||
[
|
...projectDeviceFilters,
|
||||||
device.name,
|
costGroups: retainAvailableFilterValues(
|
||||||
device.displayName,
|
projectDeviceFilters.costGroups,
|
||||||
device.category,
|
costGroupFilterOptions
|
||||||
device.connectionKind,
|
),
|
||||||
device.costGroup,
|
}),
|
||||||
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
[costGroupFilterOptions, projectDeviceFilters]
|
||||||
|
);
|
||||||
|
const visibleProjectDevices = useMemo(
|
||||||
|
() =>
|
||||||
|
filterProjectDevices(
|
||||||
|
projectDevices,
|
||||||
|
effectiveProjectDeviceFilters,
|
||||||
|
circuitGroupCategoryLabels
|
||||||
|
),
|
||||||
|
[effectiveProjectDeviceFilters, projectDevices]
|
||||||
);
|
);
|
||||||
}, [projectDeviceQuery, projectDevices]);
|
|
||||||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
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<DistributionBoardFilters>(
|
||||||
|
() => ({
|
||||||
|
...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(
|
const circuitListByBoardId = useMemo(
|
||||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||||
[circuitLists]
|
[circuitLists]
|
||||||
|
|
@ -363,6 +476,11 @@ export default function ProjectDetailPage() {
|
||||||
if (!projectId || !project) {
|
if (!projectId || !project) {
|
||||||
return;
|
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);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -850,22 +968,28 @@ export default function ProjectDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
onClick={() => setIsRevitCsvOpen(true)}
|
onClick={() => setIsRevitCsvOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Revit-CSV
|
Revit-CSV
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
onClick={() => setIsProjectSettingsOpen(true)}
|
onClick={() => setIsProjectSettingsOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<Settings aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Projekteinstellungen
|
Projekteinstellungen
|
||||||
</button>
|
</button>
|
||||||
<Link className="btn btn-outline-secondary" href="/projects">
|
<Link
|
||||||
|
className="btn btn-outline-secondary btn-with-icon"
|
||||||
|
href="/projects"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Zur Projektübersicht
|
Zur Projektübersicht
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -910,14 +1034,52 @@ export default function ProjectDetailPage() {
|
||||||
<span>Verteilungen</span>
|
<span>Verteilungen</span>
|
||||||
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="overview-actions">
|
||||||
className="btn btn-sm btn-primary"
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
label="Verteilung hinzufügen"
|
||||||
onClick={openBoardCreator}
|
onClick={openBoardCreator}
|
||||||
type="button"
|
showLabel
|
||||||
>
|
variant="primary"
|
||||||
Verteilung hinzufügen
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleFilterPanel
|
||||||
|
isFilterActive={isDistributionBoardFilterActive(
|
||||||
|
effectiveBoardFilters
|
||||||
|
)}
|
||||||
|
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
|
||||||
|
storageKey={boardFilterCollapsedStorageKey}
|
||||||
|
totalCount={boards.length}
|
||||||
|
visibleCount={visibleBoards.length}
|
||||||
|
>
|
||||||
|
<FilterSearchField
|
||||||
|
label="Suche"
|
||||||
|
onChange={(query) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, query }))
|
||||||
|
}
|
||||||
|
placeholder="Verteilungen durchsuchen …"
|
||||||
|
value={boardFilters.query}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
emptyLabel="Keine Etagen vorhanden"
|
||||||
|
label="Etage"
|
||||||
|
onChange={(floorIds) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, floorIds }))
|
||||||
|
}
|
||||||
|
options={floorFilterOptions}
|
||||||
|
selected={effectiveBoardFilters.floorIds}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
emptyLabel="Keine Netzarten vorhanden"
|
||||||
|
label="Netzart"
|
||||||
|
onChange={(supplyTypes) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, supplyTypes }))
|
||||||
|
}
|
||||||
|
options={supplyTypeFilterOptions}
|
||||||
|
selected={effectiveBoardFilters.supplyTypes}
|
||||||
|
/>
|
||||||
|
</CollapsibleFilterPanel>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-hover align-middle mb-0">
|
<table className="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -930,7 +1092,7 @@ export default function ProjectDetailPage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{boards.map((board) => {
|
{visibleBoards.map((board) => {
|
||||||
const circuitList = circuitListByBoardId.get(board.id);
|
const circuitList = circuitListByBoardId.get(board.id);
|
||||||
return (
|
return (
|
||||||
<tr key={board.id}>
|
<tr key={board.id}>
|
||||||
|
|
@ -957,22 +1119,21 @@ export default function ProjectDetailPage() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
|
<div className="overview-actions justify-content-end">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={Pencil}
|
||||||
|
label="Bearbeiten"
|
||||||
onClick={() => openBoardEditor(board)}
|
onClick={() => openBoardEditor(board)}
|
||||||
type="button"
|
/>
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
{circuitList ? (
|
{circuitList ? (
|
||||||
<Link
|
<IconLink
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||||||
>
|
icon={ListTree}
|
||||||
Stromkreisliste öffnen
|
label="Stromkreisliste öffnen"
|
||||||
</Link>
|
showLabel
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary small align-self-center">
|
<span className="text-secondary small align-self-center">
|
||||||
Keine Stromkreisliste vorhanden
|
Keine Stromkreisliste vorhanden
|
||||||
|
|
@ -992,6 +1153,11 @@ export default function ProjectDetailPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{boards.length > 0 && visibleBoards.length === 0 ? (
|
||||||
|
<p className="text-center text-secondary py-4 mb-0">
|
||||||
|
Keine Verteilungen entsprechen den Filtern.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1085,24 +1251,58 @@ export default function ProjectDetailPage() {
|
||||||
<span>Projektgeräte</span>
|
<span>Projektgeräte</span>
|
||||||
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="overview-actions">
|
||||||
<input
|
<IconButton
|
||||||
aria-label="Projektgeräte durchsuchen"
|
icon={Plus}
|
||||||
className="form-control form-control-sm"
|
label="Gerät hinzufügen"
|
||||||
onChange={(event) => setProjectDeviceQuery(event.target.value)}
|
|
||||||
placeholder="Geräte durchsuchen"
|
|
||||||
type="search"
|
|
||||||
value={projectDeviceQuery}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary text-nowrap"
|
|
||||||
onClick={() => setIsProjectDeviceModalOpen(true)}
|
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||||||
type="button"
|
showLabel
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleFilterPanel
|
||||||
|
isFilterActive={isProjectDeviceFilterActive(
|
||||||
|
effectiveProjectDeviceFilters
|
||||||
|
)}
|
||||||
|
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
|
||||||
|
storageKey={projectDeviceFilterCollapsedStorageKey}
|
||||||
|
totalCount={projectDevices.length}
|
||||||
|
visibleCount={visibleProjectDevices.length}
|
||||||
>
|
>
|
||||||
Gerät hinzufügen
|
<FilterSearchField
|
||||||
</button>
|
label="Suche"
|
||||||
</div>
|
onChange={(query) =>
|
||||||
</div>
|
setProjectDeviceFilters((current) => ({ ...current, query }))
|
||||||
|
}
|
||||||
|
placeholder="Gerät, Anschlussart, KG …"
|
||||||
|
value={projectDeviceFilters.query}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Kategorie"
|
||||||
|
onChange={(categories) =>
|
||||||
|
setProjectDeviceFilters((current) => ({ ...current, categories }))
|
||||||
|
}
|
||||||
|
options={projectDeviceCategoryFilterOptions}
|
||||||
|
selected={effectiveProjectDeviceFilters.categories}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Anschluss"
|
||||||
|
onChange={(phaseTypes) =>
|
||||||
|
setProjectDeviceFilters((current) => ({ ...current, phaseTypes }))
|
||||||
|
}
|
||||||
|
options={projectDevicePhaseTypeFilterOptions}
|
||||||
|
selected={effectiveProjectDeviceFilters.phaseTypes}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Kostengruppe"
|
||||||
|
onChange={(costGroups) =>
|
||||||
|
setProjectDeviceFilters((current) => ({ ...current, costGroups }))
|
||||||
|
}
|
||||||
|
options={costGroupFilterOptions}
|
||||||
|
selected={effectiveProjectDeviceFilters.costGroups}
|
||||||
|
/>
|
||||||
|
</CollapsibleFilterPanel>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped align-middle mb-0">
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -1121,11 +1321,13 @@ export default function ProjectDetailPage() {
|
||||||
<strong>{device.displayName}</strong>
|
<strong>{device.displayName}</strong>
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.name}
|
{device.name}
|
||||||
{device.category ? ` · ${device.category}` : ""}
|
{device.category
|
||||||
|
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
{projectDevicePhaseTypeLabels[device.phaseType]}
|
||||||
{device.connectionKind ? (
|
{device.connectionKind ? (
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.connectionKind}
|
{device.connectionKind}
|
||||||
|
|
@ -1141,39 +1343,32 @@ export default function ProjectDetailPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{device.costGroup ?? "-"}</td>
|
<td>{device.costGroup ?? "-"}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
|
<div className="overview-actions justify-content-end">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-sm btn-outline-secondary"
|
disabled={isSaving}
|
||||||
type="button"
|
icon={Pencil}
|
||||||
|
label="Bearbeiten"
|
||||||
onClick={() => setEditingProjectDevice(device)}
|
onClick={() => setEditingProjectDevice(device)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Link2}
|
||||||
Bearbeiten
|
label="Verknüpfungen"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Library}
|
||||||
Verknüpfungen
|
label="Nach global kopieren"
|
||||||
</button>
|
onClick={() => void handleCopyProjectToGlobal(device.id)}
|
||||||
<button
|
/>
|
||||||
className="btn btn-sm btn-outline-secondary"
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Trash2}
|
||||||
Nach global kopieren
|
label="Löschen"
|
||||||
</button>
|
onClick={() => void handleDeleteProjectDevice(device.id)}
|
||||||
<button
|
variant="danger"
|
||||||
className="btn btn-sm btn-outline-danger"
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteProjectDevice(device.id)}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1182,7 +1377,7 @@ export default function ProjectDetailPage() {
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-center text-secondary py-4">
|
<td colSpan={5} className="text-center text-secondary py-4">
|
||||||
{projectDevices.length
|
{projectDevices.length
|
||||||
? "Keine Projektgeräte entsprechen der Suche."
|
? "Keine Projektgeräte entsprechen den Filtern."
|
||||||
: "Noch keine Projektgeräte vorhanden."}
|
: "Noch keine Projektgeräte vorhanden."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1199,9 +1394,13 @@ export default function ProjectDetailPage() {
|
||||||
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
<div className="overview-actions">
|
||||||
Schließen
|
<IconButton
|
||||||
</button>
|
icon={X}
|
||||||
|
label="Schließen"
|
||||||
|
onClick={() => setSyncPreview(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-3 mb-3">
|
<div className="d-flex flex-wrap gap-3 mb-3">
|
||||||
|
|
@ -1280,23 +1479,23 @@ export default function ProjectDetailPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="overview-actions">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-primary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||||
>
|
icon={RefreshCw}
|
||||||
Auswahl synchronisieren
|
label="Auswahl synchronisieren"
|
||||||
</button>
|
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||||||
<button
|
showLabel
|
||||||
className="btn btn-outline-danger"
|
variant="primary"
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
<IconButton
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||||
>
|
icon={Unlink}
|
||||||
Auswahl trennen
|
label="Auswahl trennen"
|
||||||
</button>
|
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||||||
|
showLabel
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
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 {
|
import {
|
||||||
inferProjectDeviceSectionKey,
|
inferProjectDeviceSectionKey,
|
||||||
isProjectDevicePlacementValid,
|
isProjectDevicePlacementValid,
|
||||||
|
|
@ -76,6 +100,7 @@ import {
|
||||||
type MutableDistributionBoardComponentRole,
|
type MutableDistributionBoardComponentRole,
|
||||||
} from "../utils/distribution-board-component-editing";
|
} from "../utils/distribution-board-component-editing";
|
||||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
import type {
|
import type {
|
||||||
CellKey,
|
CellKey,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
|
@ -135,6 +160,7 @@ import type {
|
||||||
CircuitSnapshot,
|
CircuitSnapshot,
|
||||||
} from "../../domain/models/circuit-structure-project-command.model";
|
} from "../../domain/models/circuit-structure-project-command.model";
|
||||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||||
|
import { GzfPickerModal } from "./gzf-picker-modal";
|
||||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||||
import {
|
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}
|
||||||
|
<span className="header-unit">{match[2]}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
||||||
/*
|
/*
|
||||||
Circuit-tree editor invariant overview:
|
Circuit-tree editor invariant overview:
|
||||||
|
|
@ -276,6 +320,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
useState<StructureComponentEditorIntent | null>(null);
|
useState<StructureComponentEditorIntent | null>(null);
|
||||||
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
||||||
useState<CircuitGroupEditorIntent | null>(null);
|
useState<CircuitGroupEditorIntent | null>(null);
|
||||||
|
// GZF cell the AMEV picker writes to; null while the picker is closed.
|
||||||
|
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
|
||||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
|
|
@ -977,7 +1023,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
defaultSortOrder: number
|
defaultSortOrder: number
|
||||||
) {
|
) {
|
||||||
return buildCircuitDeviceRowInsertSnapshot({
|
return buildCircuitDeviceRowInsertSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitId,
|
circuitId,
|
||||||
values,
|
values,
|
||||||
defaultSortOrder,
|
defaultSortOrder,
|
||||||
|
|
@ -1001,7 +1047,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
const circuitId = crypto.randomUUID();
|
const circuitId = createClientId();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
);
|
);
|
||||||
|
|
@ -1169,7 +1215,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
(section) => section.id === intent.sectionId
|
(section) => section.id === intent.sectionId
|
||||||
)?.components ?? [];
|
)?.components ?? [];
|
||||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
role: intent.role,
|
role: intent.role,
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
|
|
@ -1234,7 +1280,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
projectId,
|
projectId,
|
||||||
getExpectedProjectRevision(),
|
getExpectedProjectRevision(),
|
||||||
buildNewCircuitGroupSnapshot({
|
buildNewCircuitGroupSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
category: values.category,
|
category: values.category,
|
||||||
displayName: values.displayName,
|
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.
|
// 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(
|
async function commitEdit(
|
||||||
direction: SaveDirection = "stay",
|
direction: SaveDirection = "stay",
|
||||||
draftOverride?: string
|
draftOverride?: string,
|
||||||
|
target?: SelectedCell
|
||||||
) {
|
) {
|
||||||
if (!editingCell) {
|
const session = target ?? editingCell;
|
||||||
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = findRow(editingCell.rowKey);
|
const row = findRow(session.rowKey);
|
||||||
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
|
||||||
if (!row || !cell || !cell.editable) {
|
if (!row || !cell || !cell.editable) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const draft = draftOverride ?? editingCell.draft;
|
const draft = draftOverride ?? editingCell?.draft ?? "";
|
||||||
if (
|
if (
|
||||||
editingCell.cellKey === "equipmentIdentifier" &&
|
session.cellKey === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1852,7 +1902,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
|
||||||
const nextSelectionIntent = () => {
|
const nextSelectionIntent = () => {
|
||||||
let selected = targetSelection;
|
let selected = targetSelection;
|
||||||
if (direction !== "stay") {
|
if (direction !== "stay") {
|
||||||
|
|
@ -1875,7 +1925,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const selection = await createFromPlaceholder(
|
const selection = await createFromPlaceholder(
|
||||||
row.sectionId,
|
row.sectionId,
|
||||||
editingCell.cellKey,
|
session.cellKey,
|
||||||
draft
|
draft
|
||||||
);
|
);
|
||||||
targetSelection = selection;
|
targetSelection = selection;
|
||||||
|
|
@ -1892,7 +1942,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Stromkreisfeld bearbeiten",
|
label: "Stromkreisfeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
await patchCircuit(row.circuit!.id, session.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1906,7 +1956,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Gerätefeld bearbeiten",
|
label: "Gerätefeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
await patchDeviceRow(row.device!.id, session.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1921,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
label: "Gerätezeile im Reservestromkreis erstellen",
|
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const editValues = createValuesFromEditPatch(
|
const editValues = createValuesFromEditPatch(
|
||||||
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
buildDeviceRowEditPatch(session.cellKey, next)
|
||||||
);
|
);
|
||||||
const snapshot = createDeviceRowSnapshot(
|
const snapshot = createDeviceRowSnapshot(
|
||||||
row.circuit!.id,
|
row.circuit!.id,
|
||||||
|
|
@ -1941,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
snapshot,
|
snapshot,
|
||||||
"Gerätezeile im Reservestromkreis erstellen"
|
"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();
|
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.
|
// Cancels current edit session and restores grid keyboard focus.
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
|
|
@ -2893,6 +2982,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
// - printable key starts overwrite edit (type-to-edit)
|
// - printable key starts overwrite edit (type-to-edit)
|
||||||
// - Arrow/Tab move selectedCell when not editing
|
// - Arrow/Tab move selectedCell when not editing
|
||||||
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
// 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 (editingCell) {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -2960,6 +3057,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
moveHorizontal(-1);
|
moveHorizontal(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
|
||||||
|
event.preventDefault();
|
||||||
|
openGzfPicker(selectedCell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveVertical(1);
|
moveVertical(1);
|
||||||
|
|
@ -2995,30 +3097,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Undo2}
|
||||||
Rückgängig
|
label="Rückgängig"
|
||||||
</button>
|
onClick={() => void handleUndo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleRedo()}
|
<IconButton
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Redo2}
|
||||||
Wiederholen
|
label="Wiederholen"
|
||||||
</button>
|
onClick={() => void handleRedo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={clearSortAndFilters}
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
ariaExpanded={isColumnMenuOpen}
|
||||||
|
icon={Columns3}
|
||||||
|
label="Spalten"
|
||||||
|
onClick={toggleColumnSettingsMenu}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||||
>
|
icon={FilterX}
|
||||||
Sortierung/Filter zurücksetzen
|
label="Sortierung/Filter zurücksetzen"
|
||||||
</button>
|
onClick={clearSortAndFilters}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
showLabel
|
||||||
Spalten
|
/>
|
||||||
</button>
|
|
||||||
{renderColumnSettingsMenu("col-empty")}
|
{renderColumnSettingsMenu("col-empty")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3122,6 +3229,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
onSave={handleSaveCircuitGroup}
|
onSave={handleSaveCircuitGroup}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{gzfPickerCell ? (
|
||||||
|
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
|
||||||
|
) : null}
|
||||||
{protectionEditorCircuit ? (
|
{protectionEditorCircuit ? (
|
||||||
<CircuitProtectionModal
|
<CircuitProtectionModal
|
||||||
equipmentIdentifier={
|
equipmentIdentifier={
|
||||||
|
|
@ -3140,82 +3250,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Undo2}
|
||||||
|
label="Rückgängig"
|
||||||
|
onClick={() => void handleUndo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.undoDepth
|
historyState?.undoDepth
|
||||||
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Rückgängig
|
<IconButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRedo()}
|
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Redo2}
|
||||||
|
label="Wiederholen"
|
||||||
|
onClick={() => void handleRedo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.redoDepth
|
historyState?.redoDepth
|
||||||
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Wiederholen
|
<IconButtonSeparator />
|
||||||
</button>
|
|
||||||
{isSortedView ? (
|
{isSortedView ? (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleApplySortedOrder()}
|
|
||||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||||
|
icon={Check}
|
||||||
|
label="Sortierung übernehmen"
|
||||||
|
onClick={() => void handleApplySortedOrder()}
|
||||||
|
showLabel
|
||||||
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
||||||
>
|
/>
|
||||||
Sortierung übernehmen
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
<IconButton
|
||||||
Spalten
|
ariaExpanded={isColumnMenuOpen}
|
||||||
</button>
|
icon={Columns3}
|
||||||
<button
|
label="Spalten"
|
||||||
aria-controls="project-device-drawer"
|
onClick={toggleColumnSettingsMenu}
|
||||||
aria-expanded={isProjectDeviceDrawerOpen}
|
showLabel
|
||||||
className="project-device-drawer-toggle"
|
/>
|
||||||
type="button"
|
<IconButton
|
||||||
onClick={() =>
|
disabled={!isSortedView && !hasActiveFilters}
|
||||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
icon={FilterX}
|
||||||
}
|
label="Sortierung/Filter zurücksetzen"
|
||||||
>
|
onClick={clearSortAndFilters}
|
||||||
{isProjectDeviceDrawerOpen
|
showLabel
|
||||||
? "Projektgeräte schließen"
|
/>
|
||||||
: `Projektgeräte öffnen (${projectDevices.length})`}
|
<IconButtonSeparator />
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || historyBusy}
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={FolderPlus}
|
||||||
|
label="Stromkreisgruppe hinzufügen"
|
||||||
|
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={PackagePlus}
|
||||||
|
label="Verteilergerät hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
role: "auxiliary",
|
role: "auxiliary",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
showLabel
|
||||||
Verteilergerät hinzufügen
|
/>
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
ariaControls="project-device-drawer"
|
||||||
type="button"
|
ariaExpanded={isProjectDeviceDrawerOpen}
|
||||||
disabled={isSaving || historyBusy}
|
className="project-device-drawer-toggle"
|
||||||
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
|
||||||
>
|
label={
|
||||||
Stromkreisgruppe hinzufügen
|
isProjectDeviceDrawerOpen
|
||||||
</button>
|
? "Projektgeräte schließen"
|
||||||
<button
|
: `Projektgeräte öffnen (${projectDevices.length})`
|
||||||
type="button"
|
}
|
||||||
onClick={clearSortAndFilters}
|
onClick={() =>
|
||||||
disabled={!isSortedView && !hasActiveFilters}
|
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||||
>
|
}
|
||||||
Sortierung/Filter zurücksetzen
|
showLabel
|
||||||
</button>
|
/>
|
||||||
{renderColumnSettingsMenu("col")}
|
{renderColumnSettingsMenu("col")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3405,7 +3523,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{column.label}</span>
|
<span className="header-label">
|
||||||
|
{renderColumnHeaderLabel(column.label)}
|
||||||
|
</span>
|
||||||
{sortState?.key === column.key ? (
|
{sortState?.key === column.key ? (
|
||||||
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
|
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
|
||||||
{sortState.direction === "asc" ? "↑" : "↓"}
|
{sortState.direction === "asc" ? "↑" : "↓"}
|
||||||
|
|
@ -3416,6 +3536,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
type="button"
|
type="button"
|
||||||
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
||||||
onClick={() => toggleColumnFilterMenu(column.key)}
|
onClick={() => toggleColumnFilterMenu(column.key)}
|
||||||
|
aria-label={`${getFullColumnLabel(column)} filtern`}
|
||||||
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
|
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
|
||||||
title={
|
title={
|
||||||
(columnFilters[column.key]?.length ?? 0) > 0
|
(columnFilters[column.key]?.length ?? 0) > 0
|
||||||
|
|
@ -3423,7 +3544,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
: `${getFullColumnLabel(column)} filtern`
|
: `${getFullColumnLabel(column)} filtern`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||||
|
{(columnFilters[column.key]?.length ?? 0) > 0 ? (
|
||||||
|
<span className="header-filter-count">{columnFilters[column.key]?.length}</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{openFilterColumn === column.key ? (
|
{openFilterColumn === column.key ? (
|
||||||
|
|
@ -3717,105 +3841,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={!section.category || !section.groupNumber}
|
|
||||||
onClick={() =>
|
|
||||||
setCircuitGroupEditorIntent({
|
|
||||||
kind: "edit",
|
|
||||||
section,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppe bearbeiten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
!buildCircuitGroupReorderAssignments(
|
|
||||||
data.sections,
|
|
||||||
section.id,
|
|
||||||
-1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleMoveCircuitGroup(section.id, -1)
|
|
||||||
}
|
|
||||||
title="Innerhalb derselben Kategorie nach oben verschieben"
|
|
||||||
>
|
|
||||||
Gruppe nach oben
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
!buildCircuitGroupReorderAssignments(
|
|
||||||
data.sections,
|
|
||||||
section.id,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleMoveCircuitGroup(section.id, 1)
|
|
||||||
}
|
|
||||||
title="Innerhalb derselben Kategorie nach unten verschieben"
|
|
||||||
>
|
|
||||||
Gruppe nach unten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
hasActiveSortOrFilter ||
|
|
||||||
!section.category ||
|
|
||||||
!canRenumberCircuitGroups(
|
|
||||||
data.sections,
|
|
||||||
section.category
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
section.category
|
|
||||||
? void handleRenumberCircuitGroups(
|
|
||||||
section.category
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
hasActiveSortOrFilter
|
|
||||||
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
|
||||||
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppen-BMK neu nummerieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
title={
|
icon={Plus}
|
||||||
canDeleteCircuitGroup(section)
|
label="Stromkreis hinzufügen"
|
||||||
? "Leere Stromkreisgruppe entfernen"
|
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||||
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleDeleteCircuitGroup(section)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppe entfernen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasUpstreamProtection ||
|
hasUpstreamProtection ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Vorsicherung hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3828,17 +3868,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Vorsicherung hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<span className="icon-button-text">VS</span>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasGroupRcd ||
|
hasGroupRcd ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Gruppen-FI hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3851,26 +3892,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Gruppen-FI hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disabled={isSaving}
|
|
||||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
|
||||||
>
|
>
|
||||||
Stromkreis hinzufügen
|
<span className="icon-button-text">FI</span>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButtonSeparator />
|
||||||
type="button"
|
<IconButton
|
||||||
|
disabled={!section.category || !section.groupNumber}
|
||||||
|
icon={Pencil}
|
||||||
|
label="Gruppe bearbeiten"
|
||||||
|
onClick={() =>
|
||||||
|
setCircuitGroupEditorIntent({
|
||||||
|
kind: "edit",
|
||||||
|
section,
|
||||||
|
})
|
||||||
|
}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowUp}
|
||||||
|
label="Gruppe nach oben"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, -1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach oben (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowDown}
|
||||||
|
label="Gruppe nach unten"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, 1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach unten (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
disabled={hasActiveSortOrFilter || isSaving}
|
disabled={hasActiveSortOrFilter || isSaving}
|
||||||
|
icon={ArrowDown01}
|
||||||
|
label="Bereich neu nummerieren"
|
||||||
onClick={() => void handleRenumberSection(section.id)}
|
onClick={() => void handleRenumberSection(section.id)}
|
||||||
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
tabIndex={-1}
|
||||||
>
|
title={
|
||||||
Bereich neu nummerieren
|
hasActiveSortOrFilter
|
||||||
</button>
|
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
|
||||||
|
: "Stromkreise dieser Gruppe neu nummerieren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
hasActiveSortOrFilter ||
|
||||||
|
!section.category ||
|
||||||
|
!canRenumberCircuitGroups(
|
||||||
|
data.sections,
|
||||||
|
section.category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ListOrdered}
|
||||||
|
label="Gruppen-BMK neu nummerieren"
|
||||||
|
onClick={() =>
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Gruppe entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDeleteCircuitGroup(section)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={
|
||||||
|
canDeleteCircuitGroup(section)
|
||||||
|
? "Leere Stromkreisgruppe entfernen"
|
||||||
|
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
{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") {
|
} else if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void commitEdit(event.shiftKey ? "prev" : "next");
|
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={() => {
|
onBlur={() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const active = document.activeElement as HTMLElement | null;
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!active || !containerRef.current?.contains(active)) {
|
if (!active || !containerRef.current?.contains(active)) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
focusGridWithoutScroll();
|
focusGridWithoutScroll();
|
||||||
|
|
@ -4435,6 +4572,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : column.key === "simultaneityFactor" &&
|
||||||
|
cell.editable &&
|
||||||
|
isSelected ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||||
|
className="cell-picker-trigger"
|
||||||
|
disabled={isSaving || historyBusy}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<TableProperties aria-hidden="true" size={13} />
|
||||||
|
</button>
|
||||||
|
{formatCellValue(cell.value, column.key)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
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" ? (
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={ListPlus}
|
||||||
|
label="Manuelles Gerät hinzufügen"
|
||||||
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
||||||
>
|
|
||||||
Manuelles Gerät hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Stromkreis löschen"
|
||||||
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
||||||
>
|
tabIndex={-1}
|
||||||
Stromkreis löschen
|
variant="danger"
|
||||||
</button>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{row.device ? (
|
{row.device ? (
|
||||||
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
<IconButton
|
||||||
Gerät löschen
|
disabled={isSaving}
|
||||||
</button>
|
icon={ListX}
|
||||||
|
label="Gerät löschen"
|
||||||
|
onClick={() => void handleDeleteDevice(row.device!.id)}
|
||||||
|
tabIndex={-1}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">
|
<span className="drop-hint">
|
||||||
|
|
|
||||||
120
src/frontend/components/filter-panel.tsx
Normal file
120
src/frontend/components/filter-panel.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-panel-header">
|
||||||
|
<IconButton
|
||||||
|
ariaControls={barId}
|
||||||
|
ariaExpanded={!isCollapsed}
|
||||||
|
className="filter-panel-toggle"
|
||||||
|
icon={isCollapsed ? ChevronRight : ChevronDown}
|
||||||
|
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
/>
|
||||||
|
<span className="filter-panel-label">Filter</span>
|
||||||
|
{isCollapsed && isFilterActive ? (
|
||||||
|
<span className="filter-panel-badge">Filter aktiv</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-live="polite" className="filter-panel-count">
|
||||||
|
{visibleCount} von {totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? null : (
|
||||||
|
<div className="filter-bar" id={barId}>
|
||||||
|
{children}
|
||||||
|
{isFilterActive ? (
|
||||||
|
<IconButton
|
||||||
|
className="filter-bar-reset"
|
||||||
|
icon={FilterX}
|
||||||
|
label="Zurücksetzen"
|
||||||
|
onClick={onReset}
|
||||||
|
showLabel
|
||||||
|
title="Alle Filter zurücksetzen"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search field for a filter bar. */
|
||||||
|
export function FilterSearchField({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="filter-bar-field">
|
||||||
|
<span className="filter-bar-label">{label}</span>
|
||||||
|
<input
|
||||||
|
className="filter-bar-search"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
||||||
|
|
||||||
interface GzfPickerModalProps {
|
interface GzfPickerModalProps {
|
||||||
|
|
@ -12,14 +13,50 @@ function formatFactor(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for the inline editor's blur callback before taking keyboard focus.
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
|
<div
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-modal="true"
|
||||||
|
className="modal fade show d-block"
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
|
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||||
<p className="text-secondary small mb-0">
|
<p className="text-secondary small mb-0">
|
||||||
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
59
src/frontend/components/icon-button.tsx
Normal file
59
src/frontend/components/icon-button.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
aria-controls={ariaControls}
|
||||||
|
aria-expanded={ariaExpanded}
|
||||||
|
aria-label={showLabel ? undefined : label}
|
||||||
|
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
title={title ?? label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||||
|
{showLabel ? <span>{children ?? label}</span> : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin vertical divider between groups of icon buttons. */
|
||||||
|
export function IconButtonSeparator() {
|
||||||
|
return <span aria-hidden="true" className="icon-button-separator" />;
|
||||||
|
}
|
||||||
40
src/frontend/components/icon-link.tsx
Normal file
40
src/frontend/components/icon-link.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
||||||
|
* look. `label` is always the accessible name and, unless `title` is given,
|
||||||
|
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
||||||
|
*/
|
||||||
|
export function IconLink({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
variant = "default",
|
||||||
|
showLabel = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
href: string;
|
||||||
|
variant?: "default" | "danger" | "primary";
|
||||||
|
showLabel?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
aria-label={showLabel ? undefined : label}
|
||||||
|
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||||
|
href={href}
|
||||||
|
title={title ?? label}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||||
|
{showLabel ? <span>{label}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import React, { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
toggleFilterValue,
|
||||||
|
type FilterOption,
|
||||||
|
} from "../utils/overview-filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
||||||
|
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
||||||
|
* click and Escape.
|
||||||
|
*/
|
||||||
|
export function MultiSelectDropdown({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
emptyLabel = "Keine Einträge vorhanden",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: readonly FilterOption[];
|
||||||
|
selected: readonly string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const labelId = useId();
|
||||||
|
const valueId = useId();
|
||||||
|
const panelId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
toggleRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
selected.length === 0
|
||||||
|
? "Alle"
|
||||||
|
: selected.length === 1
|
||||||
|
? options.find((option) => option.id === selected[0])?.label ??
|
||||||
|
"1 ausgewählt"
|
||||||
|
: `${selected.length} ausgewählt`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-dropdown" ref={containerRef}>
|
||||||
|
<span className="filter-bar-label" id={labelId}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-labelledby={`${labelId} ${valueId}`}
|
||||||
|
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
ref={toggleRef}
|
||||||
|
title={summary}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="filter-dropdown-value" id={valueId}>
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
className="filter-dropdown-panel"
|
||||||
|
id={panelId}
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="filter-dropdown-option filter-dropdown-option-all">
|
||||||
|
<input
|
||||||
|
checked={selected.length === 0}
|
||||||
|
onChange={() => onChange([])}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Alle
|
||||||
|
</label>
|
||||||
|
{options.map((option) => (
|
||||||
|
<label className="filter-dropdown-option" key={option.id}>
|
||||||
|
<input
|
||||||
|
checked={selected.includes(option.id)}
|
||||||
|
onChange={() =>
|
||||||
|
onChange(toggleFilterValue(selected, option.id))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,14 @@ import {
|
||||||
getProjectSnapshotRevisionDescription,
|
getProjectSnapshotRevisionDescription,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../utils/project-version-history";
|
} from "../utils/project-version-history";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Redo2,
|
||||||
|
RotateCcw,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||||
|
|
||||||
interface ProjectVersionHistoryProps {
|
interface ProjectVersionHistoryProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
|
||||||
<span className="badge text-bg-secondary">
|
<span className="badge text-bg-secondary">
|
||||||
Aktuelle Revision {currentRevision}
|
Aktuelle Revision {currentRevision}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="overview-actions">
|
||||||
className="btn btn-sm btn-outline-secondary"
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
isHistoryLoading ||
|
isHistoryLoading ||
|
||||||
!historyIsCurrent ||
|
!historyIsCurrent ||
|
||||||
!history?.undoDepth
|
!history?.undoDepth
|
||||||
}
|
}
|
||||||
|
icon={Undo2}
|
||||||
|
label="Rückgängig"
|
||||||
onClick={() => void handleHistoryCommand("undo")}
|
onClick={() => void handleHistoryCommand("undo")}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
history?.undoDepth
|
history?.undoDepth
|
||||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||||
: "Keine Änderung zum Rückgängigmachen"
|
: "Keine Änderung zum Rückgängigmachen"
|
||||||
}
|
}
|
||||||
type="button"
|
/>
|
||||||
>
|
<IconButton
|
||||||
Rückgängig
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
isHistoryLoading ||
|
isHistoryLoading ||
|
||||||
!historyIsCurrent ||
|
!historyIsCurrent ||
|
||||||
!history?.redoDepth
|
!history?.redoDepth
|
||||||
}
|
}
|
||||||
|
icon={Redo2}
|
||||||
|
label="Wiederholen"
|
||||||
onClick={() => void handleHistoryCommand("redo")}
|
onClick={() => void handleHistoryCommand("redo")}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
history?.redoDepth
|
history?.redoDepth
|
||||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||||
: "Keine Änderung zum Wiederholen"
|
: "Keine Änderung zum Wiederholen"
|
||||||
}
|
}
|
||||||
type="button"
|
/>
|
||||||
>
|
<IconButtonSeparator />
|
||||||
Wiederholen
|
<IconButton
|
||||||
</button>
|
ariaExpanded={isOpen}
|
||||||
<button
|
icon={isOpen ? ChevronUp : ChevronDown}
|
||||||
aria-expanded={isOpen}
|
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
type="button"
|
showLabel
|
||||||
>
|
/>
|
||||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<button
|
<div className="overview-actions justify-content-end">
|
||||||
className="btn btn-sm btn-outline-danger"
|
<IconButton
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Wiederherstellen"
|
||||||
onClick={() => setRestoreCandidate(snapshot)}
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
type="button"
|
variant="danger"
|
||||||
>
|
/>
|
||||||
Wiederherstellen
|
</div>
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
27
src/frontend/utils/client-id.ts
Normal file
27
src/frontend/utils/client-id.ts
Normal file
|
|
@ -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<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = 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("-");
|
||||||
|
}
|
||||||
249
src/frontend/utils/overview-filters.ts
Normal file
249
src/frontend/utils/overview-filters.ts
Normal file
|
|
@ -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<DistributionBoardDto, "supplyType">[],
|
||||||
|
labels: Readonly<Record<string, string>>
|
||||||
|
): FilterOption[] {
|
||||||
|
const offered = new Set<string>(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<DistributionBoardDto, "name" | "floorId" | "supplyType"> & {
|
||||||
|
boardNumber?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
boards: readonly Board[],
|
||||||
|
filters: DistributionBoardFilters,
|
||||||
|
floorNameById: ReadonlyMap<string, string>
|
||||||
|
): 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<ProjectDeviceDto, "costGroup">[]
|
||||||
|
): FilterOption[] {
|
||||||
|
const costGroups = new Set<string>();
|
||||||
|
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<Record<string, string>> = {}
|
||||||
|
): 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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
45
tests/client-id.test.ts
Normal file
45
tests/client-id.test.ts
Normal file
|
|
@ -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<Crypto, "getRandomValues">;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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: <T extends ArrayBufferView | null>(array: T) => {
|
||||||
|
if (array instanceof Uint8Array) {
|
||||||
|
array.fill(0xff);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
},
|
||||||
|
} as Pick<Crypto, "getRandomValues">;
|
||||||
|
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
|
||||||
|
});
|
||||||
|
});
|
||||||
271
tests/overview-filters.test.ts
Normal file
271
tests/overview-filters.test.ts
Normal file
|
|
@ -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>
|
||||||
|
): 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"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue