Compare commits
No commits in common. "3ffa50be4c4d3ba31cf203e5cc685eb25536dc99" and "2e3e85a8e52cfd1bd3434b9cc03af22bf164119d" have entirely different histories.
3ffa50be4c
...
2e3e85a8e5
20 changed files with 415 additions and 2304 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -164,53 +164,19 @@ 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,12 +4,10 @@ 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. Für SQLite stehen ein
|
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
|
implementiert.
|
||||||
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
|
||||||
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -27,9 +25,6 @@ Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
||||||
- Ä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,8 +1,5 @@
|
||||||
# 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:
|
||||||
|
|
@ -47,7 +44,6 @@ 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.
|
||||||
|
|
@ -56,23 +52,6 @@ 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.
|
||||||
|
|
@ -148,30 +127,11 @@ 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.
|
||||||
|
|
@ -201,11 +161,6 @@ 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,18 +27,9 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
||||||
data/leistungsbilanz.db (SQLite)
|
data/leistungsbilanz.db (SQLite)
|
||||||
```
|
```
|
||||||
|
|
||||||
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
||||||
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
||||||
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
|
liegt über einen Host-Mount außerhalb des Containers.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -261,31 +252,6 @@ 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,10 +16,9 @@ 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.
|
||||||
|
|
||||||
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
||||||
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
||||||
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
erreichbar gemacht werden.
|
||||||
deshalb nicht öffentlich erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## Topologie
|
## Topologie
|
||||||
|
|
||||||
|
|
@ -35,11 +34,6 @@ 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
|
||||||
|
|
@ -146,17 +140,14 @@ 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
|
||||||
|
|
||||||
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
||||||
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
|
lokalen Entwicklungsstart in der README.
|
||||||
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 den bereits vorhandenen benannten und automatischen
|
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
|
||||||
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,11 +13,6 @@ 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
|
||||||
|
|
@ -91,8 +86,7 @@ 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:3001/health
|
Invoke-WebRequest http://localhost:3000/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,263 +381,6 @@ 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 {
|
||||||
|
|
@ -688,29 +431,12 @@ 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 {
|
||||||
--lb-card-shadow:
|
border-radius: var(--radius);
|
||||||
0 4px 24px var(--shadow-soft),
|
border-top: 3px solid var(--color-primary);
|
||||||
0 1px 0 rgba(255, 255, 255, 0.7) inset;
|
box-shadow:
|
||||||
--bs-box-shadow-sm: var(--lb-card-shadow);
|
0 1px 2px var(--shadow-soft),
|
||||||
--bs-card-border-radius: 16px;
|
0 10px 24px -14px var(--shadow-strong);
|
||||||
--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 {
|
||||||
|
|
@ -761,54 +487,6 @@ 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);
|
||||||
|
|
@ -1166,37 +844,24 @@ 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: row;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
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: inline-flex;
|
display: block;
|
||||||
flex: none;
|
width: 100%;
|
||||||
align-items: center;
|
border: 1px solid var(--panel-border-strong);
|
||||||
gap: 0.15rem;
|
background: var(--panel-bg);
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-faint);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.15rem 0.2rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-grid .header-filter-btn:hover,
|
|
||||||
.tree-grid .header-filter-btn:focus-visible {
|
|
||||||
border-color: var(--panel-border-strong);
|
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-sort-btn {
|
.tree-grid .header-sort-btn {
|
||||||
|
|
@ -1214,10 +879,7 @@ 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;
|
||||||
/* Break only between words; never inside a word such as "Bemerkung". */
|
overflow-wrap: anywhere;
|
||||||
overflow-wrap: normal;
|
|
||||||
word-break: normal;
|
|
||||||
hyphens: manual;
|
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -1257,18 +919,6 @@ 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;
|
||||||
|
|
@ -1285,11 +935,6 @@ 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;
|
||||||
|
|
@ -1508,20 +1153,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .section-actions {
|
.tree-grid .section-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 0.35rem;
|
||||||
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 {
|
||||||
|
|
@ -1598,38 +1230,6 @@ 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;
|
||||||
|
|
@ -1665,7 +1265,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .action-cell {
|
.tree-grid .action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .action-cell button {
|
.tree-grid .action-cell button {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"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";
|
||||||
|
|
@ -11,20 +10,10 @@ 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,47 +3,6 @@
|
||||||
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,
|
||||||
|
|
@ -118,29 +77,6 @@ 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("");
|
||||||
|
|
@ -177,11 +113,7 @@ export default function ProjectDetailPage() {
|
||||||
useState(false);
|
useState(false);
|
||||||
const [editingProjectDevice, setEditingProjectDevice] =
|
const [editingProjectDevice, setEditingProjectDevice] =
|
||||||
useState<ProjectDeviceDto | null>(null);
|
useState<ProjectDeviceDto | null>(null);
|
||||||
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
|
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
||||||
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[]>([]);
|
||||||
|
|
@ -232,67 +164,22 @@ 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 costGroupFilterOptions = useMemo(
|
const visibleProjectDevices = useMemo(() => {
|
||||||
() => buildCostGroupFilterOptions(projectDevices),
|
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
||||||
[projectDevices]
|
if (!query) {
|
||||||
);
|
return projectDevices;
|
||||||
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
|
}
|
||||||
() => ({
|
return projectDevices.filter((device) =>
|
||||||
...projectDeviceFilters,
|
[
|
||||||
costGroups: retainAvailableFilterValues(
|
device.name,
|
||||||
projectDeviceFilters.costGroups,
|
device.displayName,
|
||||||
costGroupFilterOptions
|
device.category,
|
||||||
),
|
device.connectionKind,
|
||||||
}),
|
device.costGroup,
|
||||||
[costGroupFilterOptions, projectDeviceFilters]
|
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
||||||
);
|
|
||||||
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]
|
||||||
|
|
@ -476,11 +363,6 @@ 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 {
|
||||||
|
|
@ -968,28 +850,22 @@ 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 btn-with-icon"
|
className="btn btn-outline-primary"
|
||||||
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 btn-with-icon"
|
className="btn btn-outline-primary"
|
||||||
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
|
<Link className="btn btn-outline-secondary" href="/projects">
|
||||||
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>
|
||||||
|
|
@ -1034,52 +910,14 @@ 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>
|
||||||
<div className="overview-actions">
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-primary"
|
||||||
icon={Plus}
|
|
||||||
label="Verteilung hinzufügen"
|
|
||||||
onClick={openBoardCreator}
|
onClick={openBoardCreator}
|
||||||
showLabel
|
type="button"
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CollapsibleFilterPanel
|
|
||||||
isFilterActive={isDistributionBoardFilterActive(
|
|
||||||
effectiveBoardFilters
|
|
||||||
)}
|
|
||||||
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
|
|
||||||
storageKey={boardFilterCollapsedStorageKey}
|
|
||||||
totalCount={boards.length}
|
|
||||||
visibleCount={visibleBoards.length}
|
|
||||||
>
|
>
|
||||||
<FilterSearchField
|
Verteilung hinzufügen
|
||||||
label="Suche"
|
</button>
|
||||||
onChange={(query) =>
|
</div>
|
||||||
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>
|
||||||
|
|
@ -1092,7 +930,7 @@ export default function ProjectDetailPage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleBoards.map((board) => {
|
{boards.map((board) => {
|
||||||
const circuitList = circuitListByBoardId.get(board.id);
|
const circuitList = circuitListByBoardId.get(board.id);
|
||||||
return (
|
return (
|
||||||
<tr key={board.id}>
|
<tr key={board.id}>
|
||||||
|
|
@ -1119,21 +957,22 @@ export default function ProjectDetailPage() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="overview-actions justify-content-end">
|
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
|
||||||
<IconButton
|
<button
|
||||||
|
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 ? (
|
||||||
<IconLink
|
<Link
|
||||||
|
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}
|
>
|
||||||
label="Stromkreisliste öffnen"
|
Stromkreisliste öffnen
|
||||||
showLabel
|
</Link>
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary small align-self-center">
|
<span className="text-secondary small align-self-center">
|
||||||
Keine Stromkreisliste vorhanden
|
Keine Stromkreisliste vorhanden
|
||||||
|
|
@ -1153,11 +992,6 @@ 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>
|
||||||
|
|
@ -1251,58 +1085,24 @@ 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="overview-actions">
|
<div className="d-flex gap-2">
|
||||||
<IconButton
|
<input
|
||||||
icon={Plus}
|
aria-label="Projektgeräte durchsuchen"
|
||||||
label="Gerät hinzufügen"
|
className="form-control form-control-sm"
|
||||||
|
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)}
|
||||||
showLabel
|
type="button"
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CollapsibleFilterPanel
|
|
||||||
isFilterActive={isProjectDeviceFilterActive(
|
|
||||||
effectiveProjectDeviceFilters
|
|
||||||
)}
|
|
||||||
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
|
|
||||||
storageKey={projectDeviceFilterCollapsedStorageKey}
|
|
||||||
totalCount={projectDevices.length}
|
|
||||||
visibleCount={visibleProjectDevices.length}
|
|
||||||
>
|
>
|
||||||
<FilterSearchField
|
Gerät hinzufügen
|
||||||
label="Suche"
|
</button>
|
||||||
onChange={(query) =>
|
</div>
|
||||||
setProjectDeviceFilters((current) => ({ ...current, query }))
|
</div>
|
||||||
}
|
|
||||||
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>
|
||||||
|
|
@ -1321,13 +1121,11 @@ 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>
|
||||||
{projectDevicePhaseTypeLabels[device.phaseType]}
|
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
||||||
{device.connectionKind ? (
|
{device.connectionKind ? (
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.connectionKind}
|
{device.connectionKind}
|
||||||
|
|
@ -1343,32 +1141,39 @@ export default function ProjectDetailPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{device.costGroup ?? "-"}</td>
|
<td>{device.costGroup ?? "-"}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="overview-actions justify-content-end">
|
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
|
||||||
<IconButton
|
<button
|
||||||
disabled={isSaving}
|
className="btn btn-sm btn-outline-secondary"
|
||||||
icon={Pencil}
|
type="button"
|
||||||
label="Bearbeiten"
|
|
||||||
onClick={() => setEditingProjectDevice(device)}
|
onClick={() => setEditingProjectDevice(device)}
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
icon={Link2}
|
>
|
||||||
label="Verknüpfungen"
|
Bearbeiten
|
||||||
|
</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}
|
>
|
||||||
label="Nach global kopieren"
|
Verknüpfungen
|
||||||
onClick={() => void handleCopyProjectToGlobal(device.id)}
|
</button>
|
||||||
/>
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyProjectToGlobal(device.id)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
icon={Trash2}
|
>
|
||||||
label="Löschen"
|
Nach global kopieren
|
||||||
onClick={() => void handleDeleteProjectDevice(device.id)}
|
</button>
|
||||||
variant="danger"
|
<button
|
||||||
/>
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteProjectDevice(device.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1377,7 +1182,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 den Filtern."
|
? "Keine Projektgeräte entsprechen der Suche."
|
||||||
: "Noch keine Projektgeräte vorhanden."}
|
: "Noch keine Projektgeräte vorhanden."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1394,13 +1199,9 @@ 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>
|
||||||
<div className="overview-actions">
|
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
||||||
<IconButton
|
Schließen
|
||||||
icon={X}
|
</button>
|
||||||
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">
|
||||||
|
|
@ -1479,23 +1280,23 @@ export default function ProjectDetailPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overview-actions">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
<IconButton
|
<button
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
className="btn btn-primary"
|
||||||
icon={RefreshCw}
|
type="button"
|
||||||
label="Auswahl synchronisieren"
|
|
||||||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||||||
showLabel
|
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||||
variant="primary"
|
>
|
||||||
/>
|
Auswahl synchronisieren
|
||||||
<IconButton
|
</button>
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
<button
|
||||||
icon={Unlink}
|
className="btn btn-outline-danger"
|
||||||
label="Auswahl trennen"
|
type="button"
|
||||||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||||||
showLabel
|
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||||
variant="danger"
|
>
|
||||||
/>
|
Auswahl trennen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,6 @@
|
||||||
"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,
|
||||||
|
|
@ -100,7 +76,6 @@ 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,
|
||||||
|
|
@ -160,7 +135,6 @@ 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 {
|
||||||
|
|
@ -272,24 +246,6 @@ 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:
|
||||||
|
|
@ -320,8 +276,6 @@ 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[]>([]);
|
||||||
|
|
@ -1023,7 +977,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
defaultSortOrder: number
|
defaultSortOrder: number
|
||||||
) {
|
) {
|
||||||
return buildCircuitDeviceRowInsertSnapshot({
|
return buildCircuitDeviceRowInsertSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitId,
|
circuitId,
|
||||||
values,
|
values,
|
||||||
defaultSortOrder,
|
defaultSortOrder,
|
||||||
|
|
@ -1047,7 +1001,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
const circuitId = createClientId();
|
const circuitId = crypto.randomUUID();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
);
|
);
|
||||||
|
|
@ -1215,7 +1169,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: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
role: intent.role,
|
role: intent.role,
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
|
|
@ -1280,7 +1234,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
projectId,
|
projectId,
|
||||||
getExpectedProjectRevision(),
|
getExpectedProjectRevision(),
|
||||||
buildNewCircuitGroupSnapshot({
|
buildNewCircuitGroupSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
category: values.category,
|
category: values.category,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
|
|
@ -1875,26 +1829,22 @@ 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
|
|
||||||
) {
|
) {
|
||||||
const session = target ?? editingCell;
|
if (!editingCell) {
|
||||||
if (!session) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = findRow(session.rowKey);
|
const row = findRow(editingCell.rowKey);
|
||||||
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
|
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.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 (
|
||||||
session.cellKey === "equipmentIdentifier" &&
|
editingCell.cellKey === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1902,7 +1852,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
|
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
||||||
const nextSelectionIntent = () => {
|
const nextSelectionIntent = () => {
|
||||||
let selected = targetSelection;
|
let selected = targetSelection;
|
||||||
if (direction !== "stay") {
|
if (direction !== "stay") {
|
||||||
|
|
@ -1925,7 +1875,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,
|
||||||
session.cellKey,
|
editingCell.cellKey,
|
||||||
draft
|
draft
|
||||||
);
|
);
|
||||||
targetSelection = selection;
|
targetSelection = selection;
|
||||||
|
|
@ -1942,7 +1892,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, session.cellKey, next);
|
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1956,7 +1906,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, session.cellKey, next);
|
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1971,7 +1921,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(session.cellKey, next)
|
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
||||||
);
|
);
|
||||||
const snapshot = createDeviceRowSnapshot(
|
const snapshot = createDeviceRowSnapshot(
|
||||||
row.circuit!.id,
|
row.circuit!.id,
|
||||||
|
|
@ -1991,7 +1941,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: session.cellKey };
|
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -2000,45 +1950,6 @@ 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) {
|
||||||
|
|
@ -2982,14 +2893,6 @@ 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();
|
||||||
|
|
@ -3057,11 +2960,6 @@ 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);
|
||||||
|
|
@ -3097,35 +2995,30 @@ 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">
|
||||||
<IconButton
|
<button
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
type="button"
|
||||||
icon={Undo2}
|
|
||||||
label="Rückgängig"
|
|
||||||
onClick={() => void handleUndo()}
|
onClick={() => void handleUndo()}
|
||||||
showLabel
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
/>
|
>
|
||||||
<IconButton
|
Rückgängig
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
</button>
|
||||||
icon={Redo2}
|
<button
|
||||||
label="Wiederholen"
|
type="button"
|
||||||
onClick={() => void handleRedo()}
|
onClick={() => void handleRedo()}
|
||||||
showLabel
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
/>
|
>
|
||||||
<IconButtonSeparator />
|
Wiederholen
|
||||||
<IconButton
|
</button>
|
||||||
ariaExpanded={isColumnMenuOpen}
|
<button
|
||||||
icon={Columns3}
|
type="button"
|
||||||
label="Spalten"
|
|
||||||
onClick={toggleColumnSettingsMenu}
|
|
||||||
showLabel
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
|
||||||
icon={FilterX}
|
|
||||||
label="Sortierung/Filter zurücksetzen"
|
|
||||||
onClick={clearSortAndFilters}
|
onClick={clearSortAndFilters}
|
||||||
showLabel
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||||
/>
|
>
|
||||||
|
Sortierung/Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||||
|
Spalten
|
||||||
|
</button>
|
||||||
{renderColumnSettingsMenu("col-empty")}
|
{renderColumnSettingsMenu("col-empty")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3229,9 +3122,6 @@ 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={
|
||||||
|
|
@ -3250,90 +3140,82 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<IconButton
|
<button
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
type="button"
|
||||||
icon={Undo2}
|
|
||||||
label="Rückgängig"
|
|
||||||
onClick={() => void handleUndo()}
|
onClick={() => void handleUndo()}
|
||||||
showLabel
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
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
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<IconButton
|
Rückgängig
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
</button>
|
||||||
icon={Redo2}
|
<button
|
||||||
label="Wiederholen"
|
type="button"
|
||||||
onClick={() => void handleRedo()}
|
onClick={() => void handleRedo()}
|
||||||
showLabel
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
title={
|
title={
|
||||||
historyState?.redoDepth
|
historyState?.redoDepth
|
||||||
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<IconButtonSeparator />
|
Wiederholen
|
||||||
|
</button>
|
||||||
{isSortedView ? (
|
{isSortedView ? (
|
||||||
<IconButton
|
<button
|
||||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
type="button"
|
||||||
icon={Check}
|
|
||||||
label="Sortierung übernehmen"
|
|
||||||
onClick={() => void handleApplySortedOrder()}
|
onClick={() => void handleApplySortedOrder()}
|
||||||
showLabel
|
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||||
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}
|
||||||
<IconButton
|
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||||
ariaExpanded={isColumnMenuOpen}
|
Spalten
|
||||||
icon={Columns3}
|
</button>
|
||||||
label="Spalten"
|
<button
|
||||||
onClick={toggleColumnSettingsMenu}
|
aria-controls="project-device-drawer"
|
||||||
showLabel
|
aria-expanded={isProjectDeviceDrawerOpen}
|
||||||
/>
|
className="project-device-drawer-toggle"
|
||||||
<IconButton
|
type="button"
|
||||||
disabled={!isSortedView && !hasActiveFilters}
|
onClick={() =>
|
||||||
icon={FilterX}
|
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||||
label="Sortierung/Filter zurücksetzen"
|
}
|
||||||
onClick={clearSortAndFilters}
|
>
|
||||||
showLabel
|
{isProjectDeviceDrawerOpen
|
||||||
/>
|
? "Projektgeräte schließen"
|
||||||
<IconButtonSeparator />
|
: `Projektgeräte öffnen (${projectDevices.length})`}
|
||||||
<IconButton
|
</button>
|
||||||
|
<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
|
||||||
<IconButton
|
</button>
|
||||||
ariaControls="project-device-drawer"
|
<button
|
||||||
ariaExpanded={isProjectDeviceDrawerOpen}
|
type="button"
|
||||||
className="project-device-drawer-toggle"
|
disabled={isSaving || historyBusy}
|
||||||
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
|
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||||
label={
|
>
|
||||||
isProjectDeviceDrawerOpen
|
Stromkreisgruppe hinzufügen
|
||||||
? "Projektgeräte schließen"
|
</button>
|
||||||
: `Projektgeräte öffnen (${projectDevices.length})`
|
<button
|
||||||
}
|
type="button"
|
||||||
onClick={() =>
|
onClick={clearSortAndFilters}
|
||||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
disabled={!isSortedView && !hasActiveFilters}
|
||||||
}
|
>
|
||||||
showLabel
|
Sortierung/Filter zurücksetzen
|
||||||
/>
|
</button>
|
||||||
{renderColumnSettingsMenu("col")}
|
{renderColumnSettingsMenu("col")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3523,9 +3405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="header-label">
|
<span>{column.label}</span>
|
||||||
{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" ? "↑" : "↓"}
|
||||||
|
|
@ -3536,7 +3416,6 @@ 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
|
||||||
|
|
@ -3544,10 +3423,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
: `${getFullColumnLabel(column)} filtern`
|
: `${getFullColumnLabel(column)} filtern`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
|
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
||||||
{(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 ? (
|
||||||
|
|
@ -3841,21 +3717,105 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<IconButton
|
<button
|
||||||
disabled={isSaving}
|
type="button"
|
||||||
icon={Plus}
|
tabIndex={-1}
|
||||||
label="Stromkreis hinzufügen"
|
disabled={!section.category || !section.groupNumber}
|
||||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
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}
|
||||||
|
title={
|
||||||
|
canDeleteCircuitGroup(section)
|
||||||
|
? "Leere Stromkreisgruppe entfernen"
|
||||||
|
: "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",
|
||||||
|
|
@ -3868,18 +3828,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<span className="icon-button-text">VS</span>
|
Vorsicherung hinzufügen
|
||||||
</IconButton>
|
</button>
|
||||||
<IconButton
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
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",
|
||||||
|
|
@ -3892,112 +3851,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<span className="icon-button-text">FI</span>
|
Gruppen-FI hinzufügen
|
||||||
</IconButton>
|
</button>
|
||||||
<IconButtonSeparator />
|
<button
|
||||||
<IconButton
|
type="button"
|
||||||
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}
|
|
||||||
icon={ArrowDown01}
|
|
||||||
label="Bereich neu nummerieren"
|
|
||||||
onClick={() => void handleRenumberSection(section.id)}
|
|
||||||
tabIndex={-1}
|
|
||||||
title={
|
|
||||||
hasActiveSortOrFilter
|
|
||||||
? "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}
|
disabled={isSaving}
|
||||||
icon={Trash2}
|
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||||
label="Gruppe entfernen"
|
>
|
||||||
onClick={() =>
|
Stromkreis hinzufügen
|
||||||
void handleDeleteCircuitGroup(section)
|
</button>
|
||||||
}
|
<button
|
||||||
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title={
|
disabled={hasActiveSortOrFilter || isSaving}
|
||||||
canDeleteCircuitGroup(section)
|
onClick={() => void handleRenumberSection(section.id)}
|
||||||
? "Leere Stromkreisgruppe entfernen"
|
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
||||||
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
|
>
|
||||||
}
|
Bereich neu nummerieren
|
||||||
variant="danger"
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||||
|
|
@ -4550,21 +4423,11 @@ 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();
|
||||||
|
|
@ -4572,27 +4435,6 @@ 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)
|
||||||
)}
|
)}
|
||||||
|
|
@ -4625,32 +4467,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
>
|
>
|
||||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<button
|
||||||
|
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}
|
>
|
||||||
variant="danger"
|
Stromkreis löschen
|
||||||
/>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{row.device ? (
|
{row.device ? (
|
||||||
<IconButton
|
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||||
disabled={isSaving}
|
Gerät löschen
|
||||||
icon={ListX}
|
</button>
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"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,6 +1,5 @@
|
||||||
"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 {
|
||||||
|
|
@ -13,50 +12,14 @@ 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
|
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
|
||||||
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" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
|
<h2 className="modal-title fs-5">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>
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"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" />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"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,14 +28,6 @@ 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;
|
||||||
|
|
@ -242,50 +234,50 @@ export function ProjectVersionHistory({
|
||||||
<span className="badge text-bg-secondary">
|
<span className="badge text-bg-secondary">
|
||||||
Aktuelle Revision {currentRevision}
|
Aktuelle Revision {currentRevision}
|
||||||
</span>
|
</span>
|
||||||
<div className="overview-actions">
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-outline-secondary"
|
||||||
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 />
|
>
|
||||||
<IconButton
|
Wiederholen
|
||||||
ariaExpanded={isOpen}
|
</button>
|
||||||
icon={isOpen ? ChevronUp : ChevronDown}
|
<button
|
||||||
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
aria-expanded={isOpen}
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
showLabel
|
type="button"
|
||||||
/>
|
>
|
||||||
</div>
|
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -389,15 +381,14 @@ export function ProjectVersionHistory({
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="overview-actions justify-content-end">
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-outline-danger"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
icon={RotateCcw}
|
|
||||||
label="Wiederherstellen"
|
|
||||||
onClick={() => setRestoreCandidate(snapshot)}
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
variant="danger"
|
type="button"
|
||||||
/>
|
>
|
||||||
</div>
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* 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("-");
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
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