Compare commits

...

14 commits

Author SHA1 Message Date
3ffa50be4c Integrate consistent card styling
Document the shared card appearance. Validation: 431 tests, API/web builds, scripts and Next typechecks passed. GUI retest waived by the user.
2026-09-26 21:18:53 +02:00
2e223be8b4 Integrate project overview filters and icon actions
Document filter combinations, reset and browser persistence. Keep the search hint applicable before board information fields are introduced.

Validation: 20 focused tests, 431 total tests, API/web builds, scripts and Next typechecks, and diff checks passed. GUI retest skipped as authorized based on the colleague's prior testing.
2026-09-26 21:16:48 +02:00
641513c2ef Integrate circuit list navigation and AMEV cell picker
Preserve the ID fallback and compact editor controls. Add dialog keyboard focus, Tab containment and Escape handling; document navigation and picker behavior.

Validation: 418 tests, API/web builds, scripts and Next typechecks, isolated AMEV dialog checks and diff checks passed. Full GUI retest waived by the user based on the colleague's prior testing; the keyboard follow-up was checked in isolation.
2026-09-26 21:14:20 +02:00
203757b92c Integrate compact editor actions and fix header units 2026-09-26 21:07:36 +02:00
0a17da2b8e Align runtime and editor documentation with current implementation 2026-09-26 21:02:48 +02:00
Grovy311
68b9ee850b Search board info fields and confirm project device deletion
The distribution-board search also matches board number and location as
soon as the boards carry them (distribution-board info card of
feature/print-view); the fields are optional in the filter, so the branch
works with and without them. Deleting a project device now asks for
confirmation like the other delete actions, since the row action is a
small trash icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:16:22 +02:00
Grovy311
837b29c7c5 Add icon actions and collapsible filters to the project overview
The overview now uses the same icon buttons as the circuit list editor:
row actions are icon-only IconButtons (Pencil, Link2, Library, Trash2),
card-header and panel actions show icon plus text, and the circuit-list
link uses the new IconLink. The version history uses Undo2/Redo2, a
chevron toggle and a RotateCcw restore action. Page-header buttons stay
Bootstrap buttons with a lucide icon so the upcoming "Seitenansicht"
button of feature/print-view merges without conflicts and matches them.

The Verteilungen and Projektgeräte cards get a PM-style filter bar:
search plus MultiSelectDropdown (checkbox list with "Alle", closes on
outside click and Escape) and "Zurücksetzen" while a filter is active.
Only the bar collapses; the state is kept per card in localStorage and a
collapsed active filter shows "Filter aktiv". The header row shows
"x von y", and an empty-state row explains when filters hide everything.
The filter logic is a pure module (overview-filters.ts) with node tests;
selections that are no longer offered (deleted floor) are ignored.

IconButton now imports React explicitly like the other components that
are rendered in node tests with the classic JSX runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:01:31 +02:00
Grovy311
ead01fe5bd Keep card markup and map shadow-sm to the card shadow
Removing the shadow-sm classes touched the page markup of the project
overview, which other branches change as well. The card rule now sets
Bootstrap's --bs-box-shadow-sm to the card shadow instead, so cards with
and without shadow-sm look the same and the pages stay as they are on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:53:24 +02:00
Grovy311
51cbab1750 Give cards the surface look of the PM app
Cards had a 3px petrol bar on the top edge, and Bootstrap's shadow-sm
class overrode their own shadow with !important. They now use the PM app
surface: a 1px border all around, a 16px radius and a soft diffuse shadow
with a faint inner highlight, with a stronger shadow and a weaker highlight
in dark mode. The shadow-sm classes are removed from the cards so the
shared rule applies. Menus, drawers and dialogs keep their shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:20:09 +02:00
Grovy311
79fcb75ffb Create client ids without crypto.randomUUID
crypto.randomUUID() is only defined in secure contexts (HTTPS or
localhost). Opened over plain HTTP from another machine in the LAN, the
circuit list editor failed with "crypto.randomUUID is not a function"
whenever it created a circuit, device row, circuit group or distribution
board component, including typing into the -frei- row.

createClientId() uses crypto.randomUUID() where available and otherwise
builds an RFC 4122 version 4 UUID from crypto.getRandomValues(), which
works in every context. AGENTS.md notes the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:01:43 +02:00
Grovy311
ced4917a86 Offer the AMEV simultaneity-factor table in the circuit list
The selected GZF cell shows a small table-icon button, and Alt+ArrowDown opens
the same picker from the selected or edited cell; Escape closes it. The
shared GzfPickerModal is reused. The picked mean value is committed through
commitEdit with an explicit target cell, so it is a regular undoable edit on
device rows, compact circuits, reserve circuits and the -frei- row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:52:38 +02:00
Grovy311
db4a6c3a2e Add back arrow to the circuit list page
The arrow next to the "Stromkreisliste" heading returns to the project's
distribution board overview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:38:16 +02:00
Grovy311
d95f0bf5c0 Compact circuit list column headers with filter icons
- Replace the "Filtern" text buttons with a funnel icon next to the column
  label; an active filter is highlighted and shows its value count.
- Header labels no longer break inside words (e.g. "Bemerkung"); hyphens
  are non-breaking and a trailing unit moves to its own line, so all
  headers share one height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:30:15 +02:00
Grovy311
e394ff93f4 Use icon buttons in the circuit list editor
- New IconButton/IconButtonSeparator component based on lucide-react.
- Toolbar: icon plus text, grouped into history, view and insert blocks.
- Group headers: compact icons for add circuit, upstream fuse (VS), group
  RCD (FI), edit, move up/down, renumber and delete; the header no longer
  overflows the grid.
- Rows: add device, delete circuit (trash) and delete device (ListX).
- The previous German labels remain accessible names and tooltips;
  destructive actions highlight in the danger color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:23:36 +02:00
20 changed files with 2301 additions and 412 deletions

View file

@ -164,19 +164,53 @@ Keyboard behavior:
- Escape cancels
- Tab / Shift+Tab navigates editable cells
- Arrow keys navigate cells when not editing
- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the
selected GZF cell also shows a small table-icon button. The picked value is
committed like a typed edit.
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
`lucide-react` icons). The German action text is always the accessible name and
tooltip; the toolbar shows icon plus text, group headers and rows show icons
only, separated into logical blocks with `IconButtonSeparator`. Destructive
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
removing a device row).
Column headers show the label (click to sort) with a funnel icon for the
value filter; an active filter is highlighted and shows its value count.
Header labels break only between words and put a trailing unit such as
`[kW]` on a second line.
The project overview uses the same rule: row actions are icon-only
`IconButton`s, card-header and panel actions show icon plus text, navigation
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
the bar collapses, its state is kept per card in `localStorage`, a collapsed
active filter shows "Filter aktiv".
## Bootstrap / Styling Rule
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
Cards follow the PM app look: 1px border all around, 16px radius and the
soft shadow defined on `.card` in `globals.css` (own dark-mode value). The
`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other
`shadow-*` classes or a colored top border to cards.
Do not use Bootstrap as the core table/grid framework for the circuit list editor.
The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior.
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
## Browser Runtime
The web UI is also opened over plain HTTP from other machines in the LAN, which
is not a secure context. Do not call secure-context-only browser APIs such as
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
from `src/frontend/utils/client-id.ts`.
## Drag-and-Drop Rules
Dragging from the circuit identifier / circuit handle moves the whole circuit.

View file

@ -4,10 +4,12 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert.
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
## Unterstützter Arbeitsablauf
@ -25,6 +27,9 @@ implementiert.
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
noch keine Stromkreise oder Gerätezeilen
## Technik

View file

@ -1,5 +1,8 @@
# Circuit List Editor Interactions
The back-arrow link beside the page title returns to the project's distribution
board overview without changing circuit-list data.
## Editing Model
Inline cells are static text by default. A cell enters edit mode by:
@ -44,6 +47,7 @@ Selection, keyboard movement, and editability checks run against this normalized
- arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table
- `Delete`: delete the selected circuit or device context after confirmation
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
@ -52,6 +56,23 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## AMEV Simultaneity Factor
The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open
the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project
device dialog uses as well. "Übernehmen" commits the mean value of the chosen
consumer group through the same path as a typed edit (`commitEdit` with an
explicit target cell), so it is one undoable command and works on device rows,
compact circuits, reserve circuits and the `-frei-` row. A running inline edit
of the cell is discarded when the picker opens. The button only appears on the
selected cell; the grid keeps its static-text cells.
The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and
closes on Escape without writing a value. Closing returns focus to the grid;
only "Übernehmen" saves the selected mean value. Opening the picker is disabled
while a write or history operation is running. Its cell button stays outside
the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
@ -127,11 +148,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Filtering and Sorting
- Clicking a column label changes sorting; the funnel icon next to it opens
the value filter. An active filter is highlighted and shows the number of
selected values. Both controls retain German accessible names and tooltips.
- Header labels wrap between words; trailing units such as `[kW]` appear on
a separate line.
- Per-column filtering works on normalized displayed values.
- Sorting is view-level first and treats circuits as blocks.
- Multi-device circuits are not split during sort.
- Sorting alone does not persist order.
## Action Buttons
The editor toolbar uses icons with visible German labels. Group headers and
circuit/device rows use compact icon buttons with German accessible names and
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
their roles. Dividers separate related actions; destructive actions use the
danger style. Circuit deletion uses a trash icon, while device-row deletion
uses a crossed-out list icon.
The shared `src/frontend/components/icon-button.tsx` component preserves
disabled states and expanded states for toggles. Row and group action buttons
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
unchanged. The existing command handlers and deletion confirmations are reused.
## Apply Sorted Order
`Apply sorted order` persists current sorted block order to backend by section.
@ -161,6 +201,11 @@ across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
project-history step.
The editor creates client-side UUIDs through `createClientId` in
`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available
and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion
also works over plain HTTP from another machine in the LAN.
Covered operations include:
- insert/delete circuit

View file

@ -27,9 +27,18 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
data/leistungsbilanz.db (SQLite)
```
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
liegt über einen Host-Mount außerhalb des Containers.
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
Datenbank liegt über einen Host-Mount außerhalb des Containers.
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen
auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen
vor dem Command und bleiben über Undo/Redo unverändert.
## Wichtige Einstiegspunkte
@ -252,6 +261,31 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
damit Änderungen weiterhin niemals still synchronisiert werden.
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
`IconButton`/`IconLink`.
Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
Die Verteilersuche berücksichtigt aktuell Name und Etagenname; die Gerätesuche
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende
Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise
„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei
der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte.
Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar.
Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe
und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine
Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
bestehenden persistenten Projektbefehl.
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,

View file

@ -16,9 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
erreichbar gemacht werden.
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
deshalb nicht öffentlich erreichbar gemacht werden.
## Topologie
@ -34,6 +35,11 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue
UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das
dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese
ID-Erzeugung nicht erforderlich.
### Speichergrenzen
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
@ -140,14 +146,17 @@ Vor einer produktiven Installation werden mindestens benötigt:
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
## Backup und Wiederherstellung
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei

View file

@ -13,6 +13,11 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
angelegt. Das Frontend ist unter <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:
```powershell
@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
```powershell
docker compose up --build --detach
docker compose ps
Invoke-WebRequest http://localhost:3000/health
Invoke-WebRequest http://localhost:3001/health
Invoke-WebRequest http://localhost:3001/web-health
```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.

View file

@ -381,6 +381,263 @@ a {
font-weight: 600;
}
/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ─────
Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen
kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der
PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an
die Tokens dieser App. */
.btn-with-icon {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.overview-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.overview-actions .icon-button {
min-width: 1.9rem;
min-height: 1.85rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-ink);
font-size: 0.82rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.overview-actions .icon-button:disabled {
opacity: 0.45;
}
.overview-actions .icon-button-primary {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
.overview-actions .icon-button-primary:not(:disabled):hover {
border-color: var(--color-primary-dark);
background: var(--color-primary-dark);
color: #fff;
}
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary {
border-color: var(--color-accent);
background: var(--color-accent);
}
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover {
border-color: var(--color-accent-pale);
background: var(--color-accent-pale);
color: var(--color-primary-deep);
}
.overview-actions .icon-button-danger {
color: var(--color-danger);
}
.filter-panel {
padding: 0.55rem 1rem 0.7rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.filter-panel-header {
display: flex;
align-items: center;
gap: 10px;
}
.filter-panel-header .filter-panel-toggle {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 6px;
background: none;
color: var(--color-ink-soft);
}
.filter-panel-header .filter-panel-toggle:hover {
background: var(--color-surface);
}
.filter-panel-label,
.filter-bar-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-ink-soft);
}
.filter-panel-badge {
padding: 3px 10px;
border-radius: 999px;
background: #fbe9d9;
color: var(--color-attention-dark);
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
}
:root[data-bs-theme="dark"] .filter-panel-badge {
background: #3a2e1c;
color: #f0b880;
}
.filter-panel-count {
margin-left: auto;
font-size: 0.78rem;
color: var(--color-ink-soft);
white-space: nowrap;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-sm) var(--space-md);
margin-top: 0.5rem;
}
.filter-bar-field,
.filter-dropdown {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
}
.filter-bar-search,
.filter-dropdown-toggle {
min-height: 2.1rem;
padding: 0.35rem 0.65rem;
border: 1.5px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-ink);
font: inherit;
font-size: 0.82rem;
}
.filter-bar-search {
width: 15em;
}
.filter-bar-search:focus,
.filter-dropdown-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
border-color: var(--color-accent);
}
.filter-dropdown-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 11em;
max-width: 18em;
font-weight: 500;
cursor: pointer;
}
.filter-dropdown-toggle:hover,
.filter-dropdown-toggle.is-active {
border-color: var(--color-accent);
}
.filter-dropdown-toggle.is-active {
color: var(--color-primary);
}
:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active {
color: var(--color-accent-pale);
}
.filter-dropdown-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-dropdown-toggle svg {
flex: none;
color: var(--color-ink-soft);
}
.filter-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
padding: 8px;
border: 1.5px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
box-shadow: 0 6px 20px var(--shadow-menu);
}
.filter-dropdown-option {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 6px 8px;
border-radius: 6px;
color: var(--color-ink);
font-size: 0.82rem;
font-weight: 400;
cursor: pointer;
}
.filter-dropdown-option:hover {
background: var(--color-surface);
}
.filter-dropdown-option input {
accent-color: var(--color-primary);
}
.filter-dropdown-option-all {
margin-bottom: 2px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
border-radius: 6px 6px 0 0;
}
.filter-dropdown-empty {
padding: 6px 8px;
color: var(--color-ink-soft);
font-size: 0.8rem;
}
.filter-bar .filter-bar-reset {
min-height: 2.1rem;
padding: 0.3rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-ink);
font-size: 0.82rem;
font-weight: 500;
}
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
.kpi-grid {
@ -431,12 +688,29 @@ a.kpi:hover {
color: var(--color-accent-pale);
}
/* Flächen-Look wie in der PM-App (Vorbild grow-monitor): gleichmäßiger
1-px-Rahmen statt farbigem Balken an der Oberkante, größerer Radius und ein
weicher Streuschatten mit feiner heller Innenkante oben. Der Akzent kommt
über Kopfzeile und Inhalt, nicht über die Kante. Bootstraps shadow-sm
setzt box-shadow mit !important aus --bs-box-shadow-sm; die Karte legt diese
Variable auf denselben Schatten, damit Karten mit und ohne shadow-sm gleich
aussehen. */
.card {
border-radius: var(--radius);
border-top: 3px solid var(--color-primary);
box-shadow:
0 1px 2px var(--shadow-soft),
0 10px 24px -14px var(--shadow-strong);
--lb-card-shadow:
0 4px 24px var(--shadow-soft),
0 1px 0 rgba(255, 255, 255, 0.7) inset;
--bs-box-shadow-sm: var(--lb-card-shadow);
--bs-card-border-radius: 16px;
--bs-card-border-color: var(--color-border);
box-shadow: var(--lb-card-shadow);
}
/* Auf dunklem Grund ein kräftigerer Streuschatten und nur eine schwache
Innenkante, sonst wirkt sie wie ein harter weißer Strich. */
:root[data-bs-theme="dark"] .card {
--lb-card-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
}
.card-header {
@ -487,6 +761,54 @@ a.kpi:hover {
opacity: 0.45;
}
/* Icon buttons (circuit list editor toolbar, group and row actions) */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
line-height: 1.2;
cursor: pointer;
transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}
.icon-button svg {
flex: none;
}
.icon-button:disabled {
cursor: not-allowed;
}
.icon-button:not(:disabled):hover {
border-color: var(--accent-blue);
color: var(--accent-blue);
}
.icon-button-danger:not(:disabled):hover {
border-color: var(--color-danger);
background: var(--color-danger-bg);
color: var(--color-danger);
}
.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover {
color: #fff;
filter: brightness(1.08);
}
.icon-button-text {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.icon-button-separator {
align-self: stretch;
width: 1px;
margin: 0.1rem 0.15rem;
background: var(--panel-border-strong);
}
.editor-toolbar .project-device-drawer-toggle {
border-color: var(--accent-blue);
background: var(--accent-blue);
@ -844,24 +1166,37 @@ a.kpi:hover {
white-space: nowrap;
}
/* One header row: label (sort) on the left, filter icon on the right. */
.tree-grid .header-cell {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.2rem;
min-width: 0;
}
.tree-grid .header-filter-btn {
display: block;
width: 100%;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
display: inline-flex;
flex: none;
align-items: center;
gap: 0.15rem;
border: 1px solid transparent;
background: transparent;
color: var(--text-faint);
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.15rem 0.2rem;
white-space: nowrap;
cursor: pointer;
}
.tree-grid .header-filter-btn:hover,
.tree-grid .header-filter-btn:focus-visible {
border-color: var(--panel-border-strong);
color: var(--text-strong);
}
.tree-grid .header-sort-btn {
@ -879,7 +1214,10 @@ a.kpi:hover {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.15;
overflow-wrap: anywhere;
/* Break only between words; never inside a word such as "Bemerkung". */
overflow-wrap: normal;
word-break: normal;
hyphens: manual;
white-space: normal;
text-align: left;
cursor: pointer;
@ -919,6 +1257,18 @@ a.kpi:hover {
font-size: 0.78rem;
}
.tree-grid .header-cell .header-sort-btn {
flex: 1 1 auto;
width: auto;
align-items: flex-start;
}
.tree-grid .header-unit {
display: block;
font-weight: 500;
color: var(--text-faint);
}
.tree-grid .header-sort-btn:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline;
@ -935,6 +1285,11 @@ a.kpi:hover {
background: var(--accent-blue-soft-bg);
}
.tree-grid .header-filter-count {
min-width: 0.7rem;
text-align: center;
}
.tree-grid .header-filter-menu {
position: absolute;
z-index: 7;
@ -1153,7 +1508,20 @@ a.kpi:hover {
.tree-grid .section-actions {
display: flex;
gap: 0.35rem;
align-items: center;
gap: 0.25rem;
}
.tree-grid .section-actions .icon-button,
.tree-grid .action-cell .icon-button {
min-width: 1.75rem;
min-height: 1.6rem;
padding: 0.15rem 0.3rem;
}
.tree-grid .section-actions .icon-button:disabled,
.tree-grid .action-cell .icon-button:disabled {
opacity: 0.35;
}
.tree-grid .section-actions button {
@ -1230,6 +1598,38 @@ a.kpi:hover {
outline-offset: -2px;
}
/* Picker trigger on the selected GZF cell. Positioned over the cell so the
column width does not change while the selection moves. */
.tree-grid td:has(> .cell-picker-trigger) {
position: relative;
}
.tree-grid .cell-picker-trigger {
position: absolute;
top: 50%;
left: 0.2rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid var(--accent-blue-strong-border);
border-radius: 3px;
padding: 0;
background: var(--panel-bg);
color: var(--color-primary);
cursor: pointer;
}
:root[data-bs-theme="dark"] .tree-grid .cell-picker-trigger {
color: var(--color-accent-pale);
}
.tree-grid .cell-picker-trigger:hover {
background: var(--accent-blue-soft-bg);
}
.tree-grid .section-title {
display: flex;
align-items: baseline;
@ -1265,7 +1665,7 @@ a.kpi:hover {
.tree-grid .action-cell {
display: flex;
gap: 0.3rem;
gap: 0.25rem;
}
.tree-grid .action-cell button {

View file

@ -1,5 +1,6 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -10,10 +11,20 @@ export default function CircuitTreeEditPage() {
return (
<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 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>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div>
<div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt

View file

@ -3,6 +3,47 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
FileUp,
Library,
Link2,
ListTree,
Pencil,
Plus,
RefreshCw,
Settings,
Trash2,
Unlink,
X,
} from "lucide-react";
import { IconButton } from "../../../frontend/components/icon-button";
import { IconLink } from "../../../frontend/components/icon-link";
import {
CollapsibleFilterPanel,
FilterSearchField,
} from "../../../frontend/components/filter-panel";
import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown";
import {
circuitGroupCategories,
circuitGroupCategoryLabels,
} from "../../../shared/constants/circuit-group";
import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board";
import {
buildCostGroupFilterOptions,
buildFloorFilterOptions,
buildSupplyTypeFilterOptions,
emptyDistributionBoardFilters,
emptyProjectDeviceFilters,
filterDistributionBoards,
filterProjectDevices,
isDistributionBoardFilterActive,
isProjectDeviceFilterActive,
retainAvailableFilterValues,
type DistributionBoardFilters,
type FilterOption,
type ProjectDeviceFilters,
} from "../../../frontend/utils/overview-filters";
import {
copyGlobalDeviceToProject,
copyDistributionBoard,
@ -77,6 +118,29 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
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() {
const params = useParams<{ projectId: string }>();
const [projectId, setProjectId] = useState("");
@ -113,7 +177,11 @@ export default function ProjectDetailPage() {
useState(false);
const [editingProjectDevice, setEditingProjectDevice] =
useState<ProjectDeviceDto | null>(null);
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
emptyDistributionBoardFilters
);
const [projectDeviceFilters, setProjectDeviceFilters] =
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
@ -164,22 +232,67 @@ export default function ProjectDetailPage() {
const floorCount = useMemo(() => floors.length, [floors.length]);
const roomCount = useMemo(() => rooms.length, [rooms.length]);
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
const visibleProjectDevices = useMemo(() => {
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
if (!query) {
return projectDevices;
}
return projectDevices.filter((device) =>
[
device.name,
device.displayName,
device.category,
device.connectionKind,
device.costGroup,
].some((value) => value?.toLocaleLowerCase("de").includes(query))
const costGroupFilterOptions = useMemo(
() => buildCostGroupFilterOptions(projectDevices),
[projectDevices]
);
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
() => ({
...projectDeviceFilters,
costGroups: retainAvailableFilterValues(
projectDeviceFilters.costGroups,
costGroupFilterOptions
),
}),
[costGroupFilterOptions, projectDeviceFilters]
);
const visibleProjectDevices = useMemo(
() =>
filterProjectDevices(
projectDevices,
effectiveProjectDeviceFilters,
circuitGroupCategoryLabels
),
[effectiveProjectDeviceFilters, projectDevices]
);
}, [projectDeviceQuery, projectDevices]);
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
const 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(
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
@ -363,6 +476,11 @@ export default function ProjectDetailPage() {
if (!projectId || !project) {
return;
}
const device = projectDevices.find((item) => item.id === projectDeviceId);
const deviceName = device?.displayName || device?.name || "Projektgerät";
if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) {
return;
}
setIsSaving(true);
setError(null);
try {
@ -850,22 +968,28 @@ export default function ProjectDetailPage() {
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV
</button>
<button
className="btn btn-outline-primary"
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
<Settings aria-hidden="true" size={16} strokeWidth={2} />
Projekteinstellungen
</button>
<Link className="btn btn-outline-secondary" href="/projects">
<Link
className="btn btn-outline-secondary btn-with-icon"
href="/projects"
>
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
Zur Projektübersicht
</Link>
</div>
@ -910,14 +1034,52 @@ export default function ProjectDetailPage() {
<span>Verteilungen</span>
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<button
className="btn btn-sm btn-primary"
<div className="overview-actions">
<IconButton
icon={Plus}
label="Verteilung hinzufügen"
onClick={openBoardCreator}
type="button"
>
Verteilung hinzufügen
</button>
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isDistributionBoardFilterActive(
effectiveBoardFilters
)}
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
storageKey={boardFilterCollapsedStorageKey}
totalCount={boards.length}
visibleCount={visibleBoards.length}
>
<FilterSearchField
label="Suche"
onChange={(query) =>
setBoardFilters((current) => ({ ...current, query }))
}
placeholder="Verteilungen durchsuchen …"
value={boardFilters.query}
/>
<MultiSelectDropdown
emptyLabel="Keine Etagen vorhanden"
label="Etage"
onChange={(floorIds) =>
setBoardFilters((current) => ({ ...current, floorIds }))
}
options={floorFilterOptions}
selected={effectiveBoardFilters.floorIds}
/>
<MultiSelectDropdown
emptyLabel="Keine Netzarten vorhanden"
label="Netzart"
onChange={(supplyTypes) =>
setBoardFilters((current) => ({ ...current, supplyTypes }))
}
options={supplyTypeFilterOptions}
selected={effectiveBoardFilters.supplyTypes}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
@ -930,7 +1092,7 @@ export default function ProjectDetailPage() {
</tr>
</thead>
<tbody>
{boards.map((board) => {
{visibleBoards.map((board) => {
const circuitList = circuitListByBoardId.get(board.id);
return (
<tr key={board.id}>
@ -957,22 +1119,21 @@ export default function ProjectDetailPage() {
)}
</td>
<td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button
className="btn btn-sm btn-outline-secondary"
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => openBoardEditor(board)}
type="button"
>
Bearbeiten
</button>
/>
{circuitList ? (
<Link
className="btn btn-sm btn-primary"
<IconLink
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
>
Stromkreisliste öffnen
</Link>
icon={ListTree}
label="Stromkreisliste öffnen"
showLabel
variant="primary"
/>
) : (
<span className="text-secondary small align-self-center">
Keine Stromkreisliste vorhanden
@ -992,6 +1153,11 @@ export default function ProjectDetailPage() {
) : null}
</tbody>
</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>
</section>
@ -1085,24 +1251,58 @@ export default function ProjectDetailPage() {
<span>Projektgeräte</span>
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
</div>
<div className="d-flex gap-2">
<input
aria-label="Projektgeräte durchsuchen"
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"
<div className="overview-actions">
<IconButton
icon={Plus}
label="Gerät hinzufügen"
onClick={() => setIsProjectDeviceModalOpen(true)}
type="button"
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isProjectDeviceFilterActive(
effectiveProjectDeviceFilters
)}
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
storageKey={projectDeviceFilterCollapsedStorageKey}
totalCount={projectDevices.length}
visibleCount={visibleProjectDevices.length}
>
Gerät hinzufügen
</button>
</div>
</div>
<FilterSearchField
label="Suche"
onChange={(query) =>
setProjectDeviceFilters((current) => ({ ...current, query }))
}
placeholder="Gerät, Anschlussart, KG …"
value={projectDeviceFilters.query}
/>
<MultiSelectDropdown
label="Kategorie"
onChange={(categories) =>
setProjectDeviceFilters((current) => ({ ...current, categories }))
}
options={projectDeviceCategoryFilterOptions}
selected={effectiveProjectDeviceFilters.categories}
/>
<MultiSelectDropdown
label="Anschluss"
onChange={(phaseTypes) =>
setProjectDeviceFilters((current) => ({ ...current, phaseTypes }))
}
options={projectDevicePhaseTypeFilterOptions}
selected={effectiveProjectDeviceFilters.phaseTypes}
/>
<MultiSelectDropdown
label="Kostengruppe"
onChange={(costGroups) =>
setProjectDeviceFilters((current) => ({ ...current, costGroups }))
}
options={costGroupFilterOptions}
selected={effectiveProjectDeviceFilters.costGroups}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
@ -1121,11 +1321,13 @@ export default function ProjectDetailPage() {
<strong>{device.displayName}</strong>
<div className="small text-secondary">
{device.name}
{device.category ? ` · ${device.category}` : ""}
{device.category
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
: ""}
</div>
</td>
<td>
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
{projectDevicePhaseTypeLabels[device.phaseType]}
{device.connectionKind ? (
<div className="small text-secondary">
{device.connectionKind}
@ -1141,39 +1343,32 @@ export default function ProjectDetailPage() {
</td>
<td>{device.costGroup ?? "-"}</td>
<td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
<button
className="btn btn-sm btn-outline-secondary"
type="button"
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => setEditingProjectDevice(device)}
/>
<IconButton
disabled={isSaving}
>
Bearbeiten
</button>
<button
className="btn btn-sm btn-outline-primary"
type="button"
icon={Link2}
label="Verknüpfungen"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
/>
<IconButton
disabled={isSaving}
>
Verknüpfungen
</button>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => handleCopyProjectToGlobal(device.id)}
icon={Library}
label="Nach global kopieren"
onClick={() => void handleCopyProjectToGlobal(device.id)}
/>
<IconButton
disabled={isSaving}
>
Nach global kopieren
</button>
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteProjectDevice(device.id)}
disabled={isSaving}
>
Löschen
</button>
icon={Trash2}
label="Löschen"
onClick={() => void handleDeleteProjectDevice(device.id)}
variant="danger"
/>
</div>
</td>
</tr>
@ -1182,7 +1377,7 @@ export default function ProjectDetailPage() {
<tr>
<td colSpan={5} className="text-center text-secondary py-4">
{projectDevices.length
? "Keine Projektgeräte entsprechen der Suche."
? "Keine Projektgeräte entsprechen den Filtern."
: "Noch keine Projektgeräte vorhanden."}
</td>
</tr>
@ -1199,9 +1394,13 @@ export default function ProjectDetailPage() {
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
Schließen
</button>
<div className="overview-actions">
<IconButton
icon={X}
label="Schließen"
onClick={() => setSyncPreview(null)}
/>
</div>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
@ -1280,23 +1479,23 @@ export default function ProjectDetailPage() {
</table>
</div>
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-primary"
type="button"
onClick={() => void handleSynchronizeProjectDeviceRows()}
<div className="overview-actions">
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
>
Auswahl synchronisieren
</button>
<button
className="btn btn-outline-danger"
type="button"
onClick={() => void handleDisconnectProjectDeviceRows()}
icon={RefreshCw}
label="Auswahl synchronisieren"
onClick={() => void handleSynchronizeProjectDeviceRows()}
showLabel
variant="primary"
/>
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0}
>
Auswahl trennen
</button>
icon={Unlink}
label="Auswahl trennen"
onClick={() => void handleDisconnectProjectDeviceRows()}
showLabel
variant="danger"
/>
</div>
</div>
) : null}

View file

@ -1,6 +1,30 @@
"use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
ArrowDown01,
ArrowUp,
Check,
Columns3,
FilterX,
FolderPlus,
Funnel,
ListOrdered,
ListPlus,
ListX,
PackagePlus,
PanelRightClose,
PanelRightOpen,
Pencil,
Plus,
Redo2,
ShieldPlus,
TableProperties,
Trash2,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
@ -76,6 +100,7 @@ import {
type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import { createClientId } from "../utils/client-id";
import type {
CellKey,
ColumnDef,
@ -135,6 +160,7 @@ import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import {
@ -246,6 +272,24 @@ function createValuesFromEditPatch(
);
}
/**
* Header labels break only between words: hyphens become non-breaking and a
* trailing unit such as "[kW]" is always shown on its own second line, so
* header heights stay uniform.
*/
function renderColumnHeaderLabel(label: string) {
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
const name = (match ? match[1] : label).replace(/-/g, "‑");
return match ? (
<>
{name}
<span className="header-unit">{match[2]}</span>
</>
) : (
name
);
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/*
Circuit-tree editor invariant overview:
@ -276,6 +320,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
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] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
@ -977,7 +1023,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
defaultSortOrder: number
) {
return buildCircuitDeviceRowInsertSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitId,
values,
defaultSortOrder,
@ -1001,7 +1047,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
data
);
const circuitId = crypto.randomUUID();
const circuitId = createClientId();
const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
);
@ -1169,7 +1215,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
@ -1234,7 +1280,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitListId,
category: values.category,
displayName: values.displayName,
@ -1829,22 +1875,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
// Commits the active editor input through command history so edits remain undoable.
// `target` commits a value for a cell without an open inline editor
// (AMEV GZF picker); the regular edit path passes no target.
async function commitEdit(
direction: SaveDirection = "stay",
draftOverride?: string
draftOverride?: string,
target?: SelectedCell
) {
if (!editingCell) {
const session = target ?? editingCell;
if (!session) {
return;
}
const row = findRow(editingCell.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
const row = findRow(session.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
if (!row || !cell || !cell.editable) {
setEditingCell(null);
return;
}
const draft = draftOverride ?? editingCell.draft;
const draft = draftOverride ?? editingCell?.draft ?? "";
if (
editingCell.cellKey === "equipmentIdentifier" &&
session.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) {
@ -1852,7 +1902,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
const nextSelectionIntent = () => {
let selected = targetSelection;
if (direction !== "stay") {
@ -1875,7 +1925,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => {
const selection = await createFromPlaceholder(
row.sectionId,
editingCell.cellKey,
session.cellKey,
draft
);
targetSelection = selection;
@ -1892,7 +1942,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
redo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
await patchCircuit(row.circuit!.id, session.cellKey, next);
return nextSelectionIntent();
},
};
@ -1906,7 +1956,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
redo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
await patchDeviceRow(row.device!.id, session.cellKey, next);
return nextSelectionIntent();
},
};
@ -1921,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next)
buildDeviceRowEditPatch(session.cellKey, next)
);
const snapshot = createDeviceRowSnapshot(
row.circuit!.id,
@ -1941,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
return nextSelectionIntent();
},
};
@ -1950,6 +2000,45 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
return Boolean(
target &&
target.cellKey === "simultaneityFactor" &&
findCell(target.rowKey, target.cellKey)?.editable
);
}
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
// inline edit is discarded because the picked value replaces it.
function openGzfPicker(target: SelectedCell) {
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
return;
}
setEditingCell(null);
setSelectedCell(target);
setGzfPickerCell(target);
}
function closeGzfPicker() {
setGzfPickerCell(null);
focusGridWithoutScroll();
}
// Commits the picked mean value like a typed edit (undoable command).
function handleGzfPicked(value: number) {
const target = gzfPickerCell;
setGzfPickerCell(null);
if (!target) {
return;
}
// parseNumeric accepts only the German decimal comma.
const draft = value.toLocaleString("de-DE", {
maximumFractionDigits: 2,
useGrouping: false,
});
void commitEdit("stay", draft, target);
}
// Cancels current edit session and restores grid keyboard focus.
function cancelEdit() {
if (!editingCell) {
@ -2893,6 +2982,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// - printable key starts overwrite edit (type-to-edit)
// - Arrow/Tab move selectedCell when not editing
function handleContainerKeyDown(event: KeyboardEvent<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 (event.key === "Tab") {
event.preventDefault();
@ -2960,6 +3057,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
moveHorizontal(-1);
return;
}
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
event.preventDefault();
openGzfPicker(selectedCell);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveVertical(1);
@ -2995,30 +3097,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button
type="button"
onClick={() => void handleUndo()}
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleUndo()}
showLabel
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
>
Wiederholen
</button>
<button
type="button"
onClick={clearSortAndFilters}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleRedo()}
showLabel
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!Boolean(sortState) && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
icon={FilterX}
label="Sortierung/Filter zurücksetzen"
onClick={clearSortAndFilters}
showLabel
/>
{renderColumnSettingsMenu("col-empty")}
</div>
{renderActiveViewSummary()}
@ -3122,6 +3229,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : null}
{gzfPickerCell ? (
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
@ -3140,82 +3250,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
onClick={() => void handleUndo()}
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleUndo()}
showLabel
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleRedo()}
showLabel
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
>
Wiederholen
</button>
/>
<IconButtonSeparator />
{isSortedView ? (
<button
type="button"
onClick={() => void handleApplySortedOrder()}
<IconButton
disabled={hasActiveFilters || isSaving || historyBusy}
icon={Check}
label="Sortierung übernehmen"
onClick={() => void handleApplySortedOrder()}
showLabel
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
>
Sortierung übernehmen
</button>
/>
) : null}
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
<button
aria-controls="project-device-drawer"
aria-expanded={isProjectDeviceDrawerOpen}
className="project-device-drawer-toggle"
type="button"
onClick={() =>
setIsProjectDeviceDrawerOpen((current) => !current)
}
>
{isProjectDeviceDrawerOpen
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`}
</button>
<button
type="button"
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!isSortedView && !hasActiveFilters}
icon={FilterX}
label="Sortierung/Filter zurücksetzen"
onClick={clearSortAndFilters}
showLabel
/>
<IconButtonSeparator />
<IconButton
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={() =>
setComponentEditorIntent({
kind: "create",
role: "auxiliary",
})
}
>
Verteilergerät hinzufügen
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
>
Stromkreisgruppe hinzufügen
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!isSortedView && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
showLabel
/>
<IconButton
ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen}
className="project-device-drawer-toggle"
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
label={
isProjectDeviceDrawerOpen
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`
}
onClick={() =>
setIsProjectDeviceDrawerOpen((current) => !current)
}
showLabel
/>
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
@ -3405,7 +3523,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
>
<span>{column.label}</span>
<span className="header-label">
{renderColumnHeaderLabel(column.label)}
</span>
{sortState?.key === column.key ? (
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
{sortState.direction === "asc" ? "↑" : "↓"}
@ -3416,6 +3536,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
onClick={() => toggleColumnFilterMenu(column.key)}
aria-label={`${getFullColumnLabel(column)} filtern`}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={
(columnFilters[column.key]?.length ?? 0) > 0
@ -3423,7 +3544,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: `${getFullColumnLabel(column)} filtern`
}
>
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
{(columnFilters[column.key]?.length ?? 0) > 0 ? (
<span className="header-filter-count">{columnFilters[column.key]?.length}</span>
) : null}
</button>
</div>
{openFilterColumn === column.key ? (
@ -3717,105 +3841,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span>
</div>
<div className="section-actions">
<button
type="button"
tabIndex={-1}
disabled={!section.category || !section.groupNumber}
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
>
Gruppe bearbeiten
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
-1
)
}
onClick={() =>
void handleMoveCircuitGroup(section.id, -1)
}
title="Innerhalb derselben Kategorie nach oben verschieben"
>
Gruppe nach oben
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
1
)
}
onClick={() =>
void handleMoveCircuitGroup(section.id, 1)
}
title="Innerhalb derselben Kategorie nach unten verschieben"
>
Gruppe nach unten
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
data.sections,
section.category
)
}
onClick={() =>
section.category
? void handleRenumberCircuitGroups(
section.category
)
: undefined
}
title={
hasActiveSortOrFilter
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
}
>
Gruppen-BMK neu nummerieren
</button>
<button
type="button"
tabIndex={-1}
<IconButton
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"
icon={Plus}
label="Stromkreis hinzufügen"
onClick={() => void handleAddReserveCircuit(section.id)}
tabIndex={-1}
/>
<IconButton
disabled={
hasUpstreamProtection ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Vorsicherung hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3828,17 +3868,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
>
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1}
>
<span className="icon-button-text">VS</span>
</IconButton>
<IconButton
disabled={
hasGroupRcd ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Gruppen-FI hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3851,26 +3892,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
>
Gruppen-FI hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen
</button>
<button
type="button"
<span className="icon-button-text">FI</span>
</IconButton>
<IconButtonSeparator />
<IconButton
disabled={!section.category || !section.groupNumber}
icon={Pencil}
label="Gruppe bearbeiten"
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
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)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
Bereich neu nummerieren
</button>
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}
icon={Trash2}
label="Gruppe entfernen"
onClick={() =>
void handleDeleteCircuitGroup(section)
}
tabIndex={-1}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
}
variant="danger"
/>
</div>
</div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
@ -4423,11 +4550,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} else if (event.key === "Tab") {
event.preventDefault();
void commitEdit(event.shiftKey ? "prev" : "next");
} else if (
event.altKey &&
event.key === "ArrowDown" &&
column.key === "simultaneityFactor"
) {
event.preventDefault();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}
}}
onBlur={() => {
requestAnimationFrame(() => {
const active = document.activeElement as HTMLElement | null;
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
return;
}
if (!active || !containerRef.current?.contains(active)) {
setEditingCell(null);
focusGridWithoutScroll();
@ -4435,6 +4572,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
/>
) : column.key === "simultaneityFactor" &&
cell.editable &&
isSelected ? (
<>
<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)
)}
@ -4467,28 +4625,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<button
type="button"
tabIndex={-1}
<IconButton
disabled={isSaving}
icon={ListPlus}
label="Manuelles Gerät hinzufügen"
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
</button>
<button
type="button"
tabIndex={-1}
/>
<IconButton
disabled={isSaving}
icon={Trash2}
label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Stromkreis löschen
</button>
tabIndex={-1}
variant="danger"
/>
</>
) : null}
{row.device ? (
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">

View file

@ -0,0 +1,120 @@
"use client";
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
import React, { useEffect, useId, useState, type ReactNode } from "react";
import { IconButton } from "./icon-button";
function readCollapsed(storageKey: string) {
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
}
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
try {
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
} catch {
// Storage unavailable (private mode, blocked): keep the in-memory state.
}
}
/**
* Collapsible filter area inside a card (PM app style): a small header row
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
* button while a filter is active. Only the bar collapses, never the card.
* The collapsed state is remembered per `storageKey` (default: expanded).
*/
export function CollapsibleFilterPanel({
storageKey,
isFilterActive,
visibleCount,
totalCount,
onReset,
children,
}: {
storageKey: string;
isFilterActive: boolean;
visibleCount: number;
totalCount: number;
onReset: () => void;
children: ReactNode;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const barId = useId();
useEffect(() => {
setIsCollapsed(readCollapsed(storageKey));
}, [storageKey]);
function toggleCollapsed() {
const next = !isCollapsed;
setIsCollapsed(next);
writeCollapsed(storageKey, next);
}
return (
<div className="filter-panel">
<div className="filter-panel-header">
<IconButton
ariaControls={barId}
ariaExpanded={!isCollapsed}
className="filter-panel-toggle"
icon={isCollapsed ? ChevronRight : ChevronDown}
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
onClick={toggleCollapsed}
/>
<span className="filter-panel-label">Filter</span>
{isCollapsed && isFilterActive ? (
<span className="filter-panel-badge">Filter aktiv</span>
) : null}
<span aria-live="polite" className="filter-panel-count">
{visibleCount} von {totalCount}
</span>
</div>
{isCollapsed ? null : (
<div className="filter-bar" id={barId}>
{children}
{isFilterActive ? (
<IconButton
className="filter-bar-reset"
icon={FilterX}
label="Zurücksetzen"
onClick={onReset}
showLabel
title="Alle Filter zurücksetzen"
/>
) : null}
</div>
)}
</div>
);
}
/** Search field for a filter bar. */
export function FilterSearchField({
label,
placeholder,
value,
onChange,
}: {
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="filter-bar-field">
<span className="filter-bar-label">{label}</span>
<input
className="filter-bar-search"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
value={value}
/>
</label>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps {
@ -12,14 +13,50 @@ function formatFactor(value: number) {
}
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
const dialogRef = useRef<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 (
<>
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
<div
aria-labelledby={titleId}
aria-modal="true"
className="modal fade show d-block"
ref={dialogRef}
role="dialog"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === "Tab") {
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
const first = buttons?.[0];
const last = buttons?.[buttons.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<div>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0">
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p>

View file

@ -0,0 +1,59 @@
"use client";
import type { LucideIcon } from "lucide-react";
import React, { type ReactNode } from "react";
/**
* Compact editor button with a lucide icon. `label` is always the accessible
* name; it is also the tooltip unless a more specific `title` is given.
* With `showLabel` the label (or `children`) is rendered next to the icon.
*/
export function IconButton({
icon: Icon,
label,
title,
onClick,
disabled,
variant = "default",
showLabel = false,
children,
className,
tabIndex,
ariaExpanded,
ariaControls,
}: {
icon: LucideIcon;
label: string;
title?: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "danger" | "primary";
showLabel?: boolean;
children?: ReactNode;
className?: string;
tabIndex?: number;
ariaExpanded?: boolean;
ariaControls?: string;
}) {
return (
<button
aria-controls={ariaControls}
aria-expanded={ariaExpanded}
aria-label={showLabel ? undefined : label}
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
disabled={disabled}
onClick={onClick}
tabIndex={tabIndex}
title={title ?? label}
type="button"
>
<Icon aria-hidden="true" size={15} strokeWidth={2} />
{showLabel ? <span>{children ?? label}</span> : children}
</button>
);
}
/** Thin vertical divider between groups of icon buttons. */
export function IconButtonSeparator() {
return <span aria-hidden="true" className="icon-button-separator" />;
}

View file

@ -0,0 +1,40 @@
"use client";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
/**
* Navigation counterpart of `IconButton`: a Next.js link with the same
* look. `label` is always the accessible name and, unless `title` is given,
* the tooltip; with `showLabel` it is also rendered next to the icon.
*/
export function IconLink({
icon: Icon,
label,
title,
href,
variant = "default",
showLabel = false,
className,
}: {
icon: LucideIcon;
label: string;
title?: string;
href: string;
variant?: "default" | "danger" | "primary";
showLabel?: boolean;
className?: string;
}) {
return (
<Link
aria-label={showLabel ? undefined : label}
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
href={href}
title={title ?? label}
>
<Icon aria-hidden="true" size={15} strokeWidth={2} />
{showLabel ? <span>{label}</span> : null}
</Link>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import { ChevronDown } from "lucide-react";
import React, { useEffect, useId, useRef, useState } from "react";
import {
toggleFilterValue,
type FilterOption,
} from "../utils/overview-filters";
/**
* Filter dropdown with a checkbox list (PM app style). An empty selection
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
* click and Escape.
*/
export function MultiSelectDropdown({
label,
options,
selected,
onChange,
emptyLabel = "Keine Einträge vorhanden",
}: {
label: string;
options: readonly FilterOption[];
selected: readonly string[];
onChange: (selected: string[]) => void;
emptyLabel?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const labelId = useId();
const valueId = useId();
const panelId = useId();
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
toggleRef.current?.focus();
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const summary =
selected.length === 0
? "Alle"
: selected.length === 1
? options.find((option) => option.id === selected[0])?.label ??
"1 ausgewählt"
: `${selected.length} ausgewählt`;
return (
<div className="filter-dropdown" ref={containerRef}>
<span className="filter-bar-label" id={labelId}>
{label}
</span>
<button
aria-controls={panelId}
aria-expanded={isOpen}
aria-haspopup="true"
aria-labelledby={`${labelId} ${valueId}`}
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
onClick={() => setIsOpen((current) => !current)}
ref={toggleRef}
title={summary}
type="button"
>
<span className="filter-dropdown-value" id={valueId}>
{summary}
</span>
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
</button>
{isOpen ? (
<div
aria-labelledby={labelId}
className="filter-dropdown-panel"
id={panelId}
role="group"
>
{options.length === 0 ? (
<div className="filter-dropdown-empty">{emptyLabel}</div>
) : (
<>
<label className="filter-dropdown-option filter-dropdown-option-all">
<input
checked={selected.length === 0}
onChange={() => onChange([])}
type="checkbox"
/>
Alle
</label>
{options.map((option) => (
<label className="filter-dropdown-option" key={option.id}>
<input
checked={selected.includes(option.id)}
onChange={() =>
onChange(toggleFilterValue(selected, option.id))
}
type="checkbox"
/>
{option.label}
</label>
))}
</>
)}
</div>
) : null}
</div>
);
}

View file

@ -28,6 +28,14 @@ import {
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../utils/project-version-history";
import {
ChevronDown,
ChevronUp,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
interface ProjectVersionHistoryProps {
projectId: string;
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<button
className="btn btn-sm btn-outline-secondary"
<div className="overview-actions">
<IconButton
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleHistoryCommand("undo")}
showLabel
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
/>
<IconButton
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleHistoryCommand("redo")}
showLabel
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
type="button"
>
Wiederholen
</button>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isOpen}
icon={isOpen ? ChevronUp : ChevronDown}
label={isOpen ? "Ausblenden" : "Anzeigen"}
onClick={() => setIsOpen((current) => !current)}
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
showLabel
/>
</div>
</div>
</div>
{error ? (
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
<div className="overview-actions justify-content-end">
<IconButton
disabled={isBusy}
icon={RotateCcw}
label="Wiederherstellen"
onClick={() => setRestoreCandidate(snapshot)}
type="button"
>
Wiederherstellen
</button>
variant="danger"
/>
</div>
</td>
</tr>
))}

View file

@ -0,0 +1,27 @@
/**
* Creates a random RFC 4122 version 4 UUID for new entities built in the
* browser.
*
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
* The app is also opened over plain HTTP from other machines in the LAN, where
* it is undefined, so fall back to crypto.getRandomValues(), which is
* available in every context.
*/
export function createClientId(
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
): string {
if (typeof source.randomUUID === "function") {
return source.randomUUID();
}
const bytes = source.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}

View file

@ -0,0 +1,249 @@
import type {
DistributionBoardDto,
FloorDto,
ProjectDeviceDto,
} from "../types";
/**
* Pure filter logic for the project overview cards (Verteilungen,
* Projektgeräte). Every multi-select filter uses the PM semantics: an empty
* selection means "Alle", otherwise a record must match one selected value.
* Records without a value are addressed through the dedicated "none" ids.
*/
export interface FilterOption {
id: string;
label: string;
}
/** Option id for boards without floor ("Ohne Etage"). */
export const withoutFloorFilterId = "__without_floor__";
/** Option id for boards without supply type ("Nicht festgelegt"). */
export const withoutSupplyTypeFilterId = "__without_supply_type__";
/** Option id for project devices without cost group ("Ohne Kostengruppe"). */
export const withoutCostGroupFilterId = "__without_cost_group__";
export interface DistributionBoardFilters {
query: string;
floorIds: string[];
supplyTypes: string[];
}
export interface ProjectDeviceFilters {
query: string;
categories: string[];
phaseTypes: string[];
costGroups: string[];
}
export const emptyDistributionBoardFilters: DistributionBoardFilters = {
query: "",
floorIds: [],
supplyTypes: [],
};
export const emptyProjectDeviceFilters: ProjectDeviceFilters = {
query: "",
categories: [],
phaseTypes: [],
costGroups: [],
};
export function normalizeFilterQuery(query: string) {
return query.trim().toLocaleLowerCase("de");
}
function matchesQuery(
normalizedQuery: string,
values: readonly (string | null | undefined)[]
) {
return (
!normalizedQuery ||
values.some((value) =>
value?.toLocaleLowerCase("de").includes(normalizedQuery)
)
);
}
function matchesSelection(selected: readonly string[], value: string) {
return selected.length === 0 || selected.includes(value);
}
/** Adds or removes one value from a multi-select selection. */
export function toggleFilterValue(selected: readonly string[], value: string) {
return selected.includes(value)
? selected.filter((entry) => entry !== value)
: [...selected, value];
}
/**
* Drops selected values that are no longer offered (e.g. a deleted floor), so
* no invisible selection keeps hiding records.
*/
export function retainAvailableFilterValues(
selected: readonly string[],
options: readonly FilterOption[]
) {
const available = new Set(options.map((option) => option.id));
return selected.filter((value) => available.has(value));
}
export function isDistributionBoardFilterActive(
filters: DistributionBoardFilters
) {
return (
normalizeFilterQuery(filters.query) !== "" ||
filters.floorIds.length > 0 ||
filters.supplyTypes.length > 0
);
}
export function isProjectDeviceFilterActive(filters: ProjectDeviceFilters) {
return (
normalizeFilterQuery(filters.query) !== "" ||
filters.categories.length > 0 ||
filters.phaseTypes.length > 0 ||
filters.costGroups.length > 0
);
}
/** Floors in their configured order, followed by "Ohne Etage". */
export function buildFloorFilterOptions(
floors: readonly FloorDto[]
): FilterOption[] {
return [
...[...floors]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.name.localeCompare(right.name, "de", { numeric: true })
)
.map((floor) => ({ id: floor.id, label: floor.name })),
{ id: withoutFloorFilterId, label: "Ohne Etage" },
];
}
/**
* Supply types in catalog order: every enabled or used type, plus
* "Nicht festgelegt" when a board has no supply type.
*/
export function buildSupplyTypeFilterOptions(
catalog: readonly string[],
enabledSupplyTypes: readonly string[],
boards: readonly Pick<DistributionBoardDto, "supplyType">[],
labels: Readonly<Record<string, string>>
): FilterOption[] {
const offered = new Set<string>(enabledSupplyTypes);
let hasBoardWithoutSupplyType = false;
for (const board of boards) {
if (board.supplyType) {
offered.add(board.supplyType);
} else {
hasBoardWithoutSupplyType = true;
}
}
const options: FilterOption[] = catalog
.filter((supplyType) => offered.has(supplyType))
.map((supplyType) => ({
id: supplyType,
label: labels[supplyType] ?? supplyType,
}));
if (hasBoardWithoutSupplyType) {
options.push({ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" });
}
return options;
}
/**
* Board number and location come with the distribution-board info card
* (feature/print-view). They are optional here so the filter works with and
* without those fields and searches them as soon as the DTO carries them.
*/
export function filterDistributionBoards<
Board extends Pick<DistributionBoardDto, "name" | "floorId" | "supplyType"> & {
boardNumber?: string | null;
location?: string | null;
},
>(
boards: readonly Board[],
filters: DistributionBoardFilters,
floorNameById: ReadonlyMap<string, string>
): Board[] {
const query = normalizeFilterQuery(filters.query);
return boards.filter(
(board) =>
matchesQuery(query, [
board.name,
board.boardNumber,
board.location,
board.floorId ? floorNameById.get(board.floorId) : undefined,
]) &&
matchesSelection(
filters.floorIds,
board.floorId ?? withoutFloorFilterId
) &&
matchesSelection(
filters.supplyTypes,
board.supplyType ?? withoutSupplyTypeFilterId
)
);
}
function getCostGroupFilterValue(costGroup: string | null) {
const trimmed = costGroup?.trim() ?? "";
return trimmed || withoutCostGroupFilterId;
}
/** Distinct cost groups in natural order, followed by "Ohne Kostengruppe". */
export function buildCostGroupFilterOptions(
devices: readonly Pick<ProjectDeviceDto, "costGroup">[]
): FilterOption[] {
const costGroups = new Set<string>();
for (const device of devices) {
const value = getCostGroupFilterValue(device.costGroup);
if (value !== withoutCostGroupFilterId) {
costGroups.add(value);
}
}
return [
...[...costGroups]
.sort((left, right) => left.localeCompare(right, "de", { numeric: true }))
.map((costGroup) => ({ id: costGroup, label: costGroup })),
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
];
}
export function filterProjectDevices<
Device extends Pick<
ProjectDeviceDto,
| "name"
| "displayName"
| "category"
| "phaseType"
| "connectionKind"
| "costGroup"
>,
>(
devices: readonly Device[],
filters: ProjectDeviceFilters,
categoryLabels: Readonly<Record<string, string>> = {}
): Device[] {
const query = normalizeFilterQuery(filters.query);
return devices.filter(
(device) =>
matchesQuery(query, [
device.name,
device.displayName,
device.category,
categoryLabels[device.category],
device.connectionKind,
device.costGroup,
]) &&
matchesSelection(filters.categories, device.category) &&
matchesSelection(filters.phaseTypes, device.phaseType) &&
matchesSelection(
filters.costGroups,
getCostGroupFilterValue(device.costGroup)
)
);
}

45
tests/client-id.test.ts Normal file
View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import { describe, it } from "node:test";
import { z } from "zod";
import { createClientId } from "../src/frontend/utils/client-id.js";
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("client id", () => {
it("uses crypto.randomUUID when the context provides it", () => {
const id = createClientId({
getRandomValues: () => {
throw new Error("fallback must not be used");
},
randomUUID: () => "11111111-2222-4333-8444-555555555555",
});
assert.equal(id, "11111111-2222-4333-8444-555555555555");
});
it("falls back to getRandomValues outside a secure context", () => {
const insecure = {
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
} as Pick<Crypto, "getRandomValues">;
const ids = new Set<string>();
for (let index = 0; index < 200; index += 1) {
const id = createClientId(insecure);
assert.match(id, uuidV4);
assert.equal(z.string().uuid().safeParse(id).success, true);
ids.add(id);
}
assert.equal(ids.size, 200);
});
it("sets version and variant bits regardless of the random bytes", () => {
const allOnes = {
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
if (array instanceof Uint8Array) {
array.fill(0xff);
}
return array;
},
} as Pick<Crypto, "getRandomValues">;
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
});
});

View file

@ -0,0 +1,271 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCostGroupFilterOptions,
buildFloorFilterOptions,
buildSupplyTypeFilterOptions,
emptyDistributionBoardFilters,
emptyProjectDeviceFilters,
filterDistributionBoards,
filterProjectDevices,
isDistributionBoardFilterActive,
isProjectDeviceFilterActive,
retainAvailableFilterValues,
toggleFilterValue,
withoutCostGroupFilterId,
withoutFloorFilterId,
withoutSupplyTypeFilterId,
} from "../src/frontend/utils/overview-filters.js";
import type {
DistributionBoardDto,
FloorDto,
ProjectDeviceDto,
} from "../src/frontend/types.js";
type TestBoard = Pick<
DistributionBoardDto,
"id" | "name" | "floorId" | "supplyType"
>;
function board(
id: string,
name: string,
floorId: string | null,
supplyType: DistributionBoardDto["supplyType"]
): TestBoard {
return { id, name, floorId, supplyType };
}
function device(
id: string,
overrides: Partial<ProjectDeviceDto>
): ProjectDeviceDto {
return {
id,
projectId: "project-1",
name: `device-${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "single_phase",
quantity: 1,
powerPerUnit: 1,
simultaneityFactor: 1,
totalPower: 1,
cosPhi: null,
remark: null,
voltageV: null,
...overrides,
};
}
const floors: FloorDto[] = [
{ id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 },
{ id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 },
];
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const boards = [
board("b1", "UV-EG-01", "floor-eg", "AV"),
board("b2", "UV-OG-01", "floor-og", "SV"),
board("b3", "HV", null, "AV"),
board("b4", "SiBe-Zentrale", null, null),
];
describe("overview filters: distribution boards", () => {
it("returns every board without an active filter", () => {
assert.equal(isDistributionBoardFilterActive(emptyDistributionBoardFilters), false);
assert.deepEqual(
filterDistributionBoards(boards, emptyDistributionBoardFilters, floorNameById),
boards
);
});
it("also searches board number and location when the boards carry them", () => {
const withInfo = [
{ ...boards[0]!, boardNumber: "UV 0.1", location: "Raum 003 Technik" },
{ ...boards[1]!, boardNumber: null, location: null },
];
assert.deepEqual(
filterDistributionBoards(
withInfo,
{ ...emptyDistributionBoardFilters, query: "uv 0.1" },
floorNameById
).map((entry) => entry.id),
[withInfo[0]!.id]
);
assert.deepEqual(
filterDistributionBoards(
withInfo,
{ ...emptyDistributionBoardFilters, query: "technik" },
floorNameById
).map((entry) => entry.id),
[withInfo[0]!.id]
);
});
it("searches board and floor names case-insensitively and ignores blank queries", () => {
const byFloorName = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, query: " og " },
floorNameById
);
assert.deepEqual(byFloorName.map((entry) => entry.id), ["b2"]);
const byBoardName = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, query: "sibe" },
floorNameById
);
assert.deepEqual(byBoardName.map((entry) => entry.id), ["b4"]);
assert.equal(
isDistributionBoardFilterActive({ ...emptyDistributionBoardFilters, query: " " }),
false
);
});
it("filters floors with the dedicated option for boards without floor", () => {
const result = filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, floorIds: ["floor-eg", withoutFloorFilterId] },
floorNameById
);
assert.deepEqual(result.map((entry) => entry.id), ["b1", "b3", "b4"]);
});
it("combines supply type with the other criteria", () => {
const filters = {
query: "",
floorIds: [withoutFloorFilterId],
supplyTypes: ["AV"],
};
assert.equal(isDistributionBoardFilterActive(filters), true);
assert.deepEqual(
filterDistributionBoards(boards, filters, floorNameById).map((entry) => entry.id),
["b3"]
);
assert.deepEqual(
filterDistributionBoards(
boards,
{ ...emptyDistributionBoardFilters, supplyTypes: [withoutSupplyTypeFilterId] },
floorNameById
).map((entry) => entry.id),
["b4"]
);
});
it("offers floors in configured order followed by 'Ohne Etage'", () => {
assert.deepEqual(buildFloorFilterOptions(floors), [
{ id: "floor-eg", label: "EG" },
{ id: "floor-og", label: "1. OG" },
{ id: withoutFloorFilterId, label: "Ohne Etage" },
]);
});
it("offers enabled and used supply types in catalog order", () => {
const options = buildSupplyTypeFilterOptions(
["AV", "SV", "EV", "USV"],
["USV", "AV"],
boards,
{ AV: "AV – Allgemein", SV: "SV – Sicherheit" }
);
assert.deepEqual(options, [
{ id: "AV", label: "AV – Allgemein" },
{ id: "SV", label: "SV – Sicherheit" },
{ id: "USV", label: "USV" },
{ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" },
]);
});
});
describe("overview filters: project devices", () => {
const devices = [
device("d1", {
displayName: "Steckdose Büro",
costGroup: "444",
connectionKind: "Steckdose",
}),
device("d2", {
displayName: "Durchlauferhitzer",
phaseType: "three_phase",
category: "three_phase",
costGroup: " 445 ",
}),
device("d3", {
displayName: "Downlight",
category: "lighting",
costGroup: " ",
}),
device("d4", { displayName: "Kaffeemaschine", costGroup: null }),
];
it("searches names, connection kind, cost group and the German category label", () => {
assert.deepEqual(
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "STECK" }).map(
(entry) => entry.id
),
["d1"]
);
assert.deepEqual(
filterProjectDevices(
devices,
{ ...emptyProjectDeviceFilters, query: "beleucht" },
{ lighting: "Beleuchtung" }
).map((entry) => entry.id),
["d3"]
);
});
it("filters category, phase type and cost group together", () => {
const filters = {
query: "",
categories: ["single_phase", "three_phase"],
phaseTypes: ["three_phase"],
costGroups: ["445"],
};
assert.equal(isProjectDeviceFilterActive(filters), true);
assert.deepEqual(
filterProjectDevices(devices, filters).map((entry) => entry.id),
["d2"]
);
});
it("treats blank cost groups as 'Ohne Kostengruppe'", () => {
assert.deepEqual(
filterProjectDevices(devices, {
...emptyProjectDeviceFilters,
costGroups: [withoutCostGroupFilterId],
}).map((entry) => entry.id),
["d3", "d4"]
);
assert.deepEqual(buildCostGroupFilterOptions(devices), [
{ id: "444", label: "444" },
{ id: "445", label: "445" },
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
]);
});
it("returns an empty list when the filters hide every device", () => {
assert.deepEqual(
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "gibt es nicht" }),
[]
);
assert.equal(isProjectDeviceFilterActive(emptyProjectDeviceFilters), false);
});
});
describe("overview filters: selection helpers", () => {
it("toggles values without mutating the selection", () => {
const selected = ["a"];
assert.deepEqual(toggleFilterValue(selected, "b"), ["a", "b"]);
assert.deepEqual(toggleFilterValue(selected, "a"), []);
assert.deepEqual(selected, ["a"]);
});
it("drops selected values that are no longer offered", () => {
assert.deepEqual(
retainAvailableFilterValues(["floor-eg", "deleted-floor"], buildFloorFilterOptions(floors)),
["floor-eg"]
);
});
});