Compare commits

..

No commits in common. "3ffa50be4c4d3ba31cf203e5cc685eb25536dc99" and "2e3e85a8e52cfd1bd3434b9cc03af22bf164119d" have entirely different histories.

20 changed files with 415 additions and 2304 deletions

View file

@ -164,53 +164,19 @@ 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,12 +4,10 @@ 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. 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.
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.
## Unterstützter Arbeitsablauf
@ -27,9 +25,6 @@ Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- 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,8 +1,5 @@
# 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:
@ -47,7 +44,6 @@ 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.
@ -56,23 +52,6 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## 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.
@ -148,30 +127,11 @@ 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.
@ -201,11 +161,6 @@ across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
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,18 +27,9 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
data/leistungsbilanz.db (SQLite)
```
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.
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.
## Wichtige Einstiegspunkte
@ -261,31 +252,6 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
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,10 +16,9 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
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.
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
erreichbar gemacht werden.
## Topologie
@ -35,11 +34,6 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
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
@ -146,17 +140,14 @@ Vor einer produktiven Installation werden mindestens benötigt:
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
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.
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
## 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 den bereits vorhandenen benannten und automatischen
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei

View file

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

View file

@ -381,263 +381,6 @@ 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 {
@ -688,29 +431,12 @@ 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 {
--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;
border-radius: var(--radius);
border-top: 3px solid var(--color-primary);
box-shadow:
0 1px 2px var(--shadow-soft),
0 10px 24px -14px var(--shadow-strong);
}
.card-header {
@ -761,54 +487,6 @@ 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);
@ -1166,37 +844,24 @@ 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: row;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
min-width: 0;
}
.tree-grid .header-filter-btn {
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.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);
display: block;
width: 100%;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
white-space: nowrap;
}
.tree-grid .header-sort-btn {
@ -1214,10 +879,7 @@ a.kpi:hover {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.15;
/* Break only between words; never inside a word such as "Bemerkung". */
overflow-wrap: normal;
word-break: normal;
hyphens: manual;
overflow-wrap: anywhere;
white-space: normal;
text-align: left;
cursor: pointer;
@ -1257,18 +919,6 @@ 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;
@ -1285,11 +935,6 @@ 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;
@ -1508,20 +1153,7 @@ a.kpi:hover {
.tree-grid .section-actions {
display: flex;
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;
gap: 0.35rem;
}
.tree-grid .section-actions button {
@ -1598,38 +1230,6 @@ 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;
@ -1665,7 +1265,7 @@ a.kpi:hover {
.tree-grid .action-cell {
display: flex;
gap: 0.25rem;
gap: 0.3rem;
}
.tree-grid .action-cell button {

View file

@ -1,6 +1,5 @@
"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";
@ -11,20 +10,10 @@ 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,47 +3,6 @@
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,
@ -118,29 +77,6 @@ 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("");
@ -177,11 +113,7 @@ export default function ProjectDetailPage() {
useState(false);
const [editingProjectDevice, setEditingProjectDevice] =
useState<ProjectDeviceDto | null>(null);
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
emptyDistributionBoardFilters
);
const [projectDeviceFilters, setProjectDeviceFilters] =
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
@ -232,67 +164,22 @@ 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 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]
const visibleProjectDevices = useMemo(() => {
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
if (!query) {
return projectDevices;
}
return projectDevices.filter((device) =>
[
device.name,
device.displayName,
device.category,
device.connectionKind,
device.costGroup,
].some((value) => value?.toLocaleLowerCase("de").includes(query))
);
}, [projectDeviceQuery, projectDevices]);
const 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]
@ -476,11 +363,6 @@ 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 {
@ -968,28 +850,22 @@ export default function ProjectDetailPage() {
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary btn-with-icon"
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV
</button>
<button
className="btn btn-outline-primary btn-with-icon"
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
<Settings aria-hidden="true" size={16} strokeWidth={2} />
Projekteinstellungen
</button>
<Link
className="btn btn-outline-secondary btn-with-icon"
href="/projects"
>
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
<Link className="btn btn-outline-secondary" href="/projects">
Zur Projektübersicht
</Link>
</div>
@ -1034,52 +910,14 @@ export default function ProjectDetailPage() {
<span>Verteilungen</span>
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<div className="overview-actions">
<IconButton
icon={Plus}
label="Verteilung hinzufügen"
<button
className="btn btn-sm btn-primary"
onClick={openBoardCreator}
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isDistributionBoardFilterActive(
effectiveBoardFilters
)}
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
storageKey={boardFilterCollapsedStorageKey}
totalCount={boards.length}
visibleCount={visibleBoards.length}
type="button"
>
<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>
Verteilung hinzufügen
</button>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
@ -1092,7 +930,7 @@ export default function ProjectDetailPage() {
</tr>
</thead>
<tbody>
{visibleBoards.map((board) => {
{boards.map((board) => {
const circuitList = circuitListByBoardId.get(board.id);
return (
<tr key={board.id}>
@ -1119,21 +957,22 @@ export default function ProjectDetailPage() {
)}
</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button
className="btn btn-sm btn-outline-secondary"
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => openBoardEditor(board)}
/>
type="button"
>
Bearbeiten
</button>
{circuitList ? (
<IconLink
<Link
className="btn btn-sm btn-primary"
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
icon={ListTree}
label="Stromkreisliste öffnen"
showLabel
variant="primary"
/>
>
Stromkreisliste öffnen
</Link>
) : (
<span className="text-secondary small align-self-center">
Keine Stromkreisliste vorhanden
@ -1153,11 +992,6 @@ 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>
@ -1251,58 +1085,24 @@ export default function ProjectDetailPage() {
<span>Projektgeräte</span>
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
</div>
<div className="overview-actions">
<IconButton
icon={Plus}
label="Gerät hinzufügen"
<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"
onClick={() => setIsProjectDeviceModalOpen(true)}
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isProjectDeviceFilterActive(
effectiveProjectDeviceFilters
)}
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
storageKey={projectDeviceFilterCollapsedStorageKey}
totalCount={projectDevices.length}
visibleCount={visibleProjectDevices.length}
type="button"
>
<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>
Gerät hinzufügen
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
@ -1321,13 +1121,11 @@ export default function ProjectDetailPage() {
<strong>{device.displayName}</strong>
<div className="small text-secondary">
{device.name}
{device.category
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
: ""}
{device.category ? ` · ${device.category}` : ""}
</div>
</td>
<td>
{projectDevicePhaseTypeLabels[device.phaseType]}
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
{device.connectionKind ? (
<div className="small text-secondary">
{device.connectionKind}
@ -1343,32 +1141,39 @@ export default function ProjectDetailPage() {
</td>
<td>{device.costGroup ?? "-"}</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
<button
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => setEditingProjectDevice(device)}
/>
<IconButton
disabled={isSaving}
icon={Link2}
label="Verknüpfungen"
>
Bearbeiten
</button>
<button
className="btn btn-sm btn-outline-primary"
type="button"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
/>
<IconButton
disabled={isSaving}
icon={Library}
label="Nach global kopieren"
onClick={() => void handleCopyProjectToGlobal(device.id)}
/>
<IconButton
>
Verknüpfungen
</button>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => handleCopyProjectToGlobal(device.id)}
disabled={isSaving}
icon={Trash2}
label="Löschen"
onClick={() => void handleDeleteProjectDevice(device.id)}
variant="danger"
/>
>
Nach global kopieren
</button>
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteProjectDevice(device.id)}
disabled={isSaving}
>
Löschen
</button>
</div>
</td>
</tr>
@ -1377,7 +1182,7 @@ export default function ProjectDetailPage() {
<tr>
<td colSpan={5} className="text-center text-secondary py-4">
{projectDevices.length
? "Keine Projektgeräte entsprechen den Filtern."
? "Keine Projektgeräte entsprechen der Suche."
: "Noch keine Projektgeräte vorhanden."}
</td>
</tr>
@ -1394,13 +1199,9 @@ export default function ProjectDetailPage() {
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<div className="overview-actions">
<IconButton
icon={X}
label="Schließen"
onClick={() => setSyncPreview(null)}
/>
</div>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
Schließen
</button>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
@ -1479,23 +1280,23 @@ export default function ProjectDetailPage() {
</table>
</div>
<div className="overview-actions">
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
icon={RefreshCw}
label="Auswahl synchronisieren"
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-primary"
type="button"
onClick={() => void handleSynchronizeProjectDeviceRows()}
showLabel
variant="primary"
/>
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0}
icon={Unlink}
label="Auswahl trennen"
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
>
Auswahl synchronisieren
</button>
<button
className="btn btn-outline-danger"
type="button"
onClick={() => void handleDisconnectProjectDeviceRows()}
showLabel
variant="danger"
/>
disabled={isSaving || selectedSyncRowIds.length === 0}
>
Auswahl trennen
</button>
</div>
</div>
) : null}

View file

@ -1,30 +1,6 @@
"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,
@ -100,7 +76,6 @@ 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,
@ -160,7 +135,6 @@ 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 {
@ -272,24 +246,6 @@ function createValuesFromEditPatch(
);
}
/**
* Header labels break only between words: hyphens become non-breaking and a
* trailing unit such as "[kW]" is always shown on its own second line, so
* header heights stay uniform.
*/
function renderColumnHeaderLabel(label: string) {
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
const name = (match ? match[1] : label).replace(/-/g, "‑");
return match ? (
<>
{name}
<span className="header-unit">{match[2]}</span>
</>
) : (
name
);
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/*
Circuit-tree editor invariant overview:
@ -320,8 +276,6 @@ 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[]>([]);
@ -1023,7 +977,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
defaultSortOrder: number
) {
return buildCircuitDeviceRowInsertSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitId,
values,
defaultSortOrder,
@ -1047,7 +1001,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
data
);
const circuitId = createClientId();
const circuitId = crypto.randomUUID();
const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
);
@ -1215,7 +1169,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
@ -1280,7 +1234,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitListId,
category: values.category,
displayName: values.displayName,
@ -1875,26 +1829,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
// Commits the active editor input through command history so edits remain undoable.
// `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,
target?: SelectedCell
draftOverride?: string
) {
const session = target ?? editingCell;
if (!session) {
if (!editingCell) {
return;
}
const row = findRow(session.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
const row = findRow(editingCell.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
if (!row || !cell || !cell.editable) {
setEditingCell(null);
return;
}
const draft = draftOverride ?? editingCell?.draft ?? "";
const draft = draftOverride ?? editingCell.draft;
if (
session.cellKey === "equipmentIdentifier" &&
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) {
@ -1902,7 +1852,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
const nextSelectionIntent = () => {
let selected = targetSelection;
if (direction !== "stay") {
@ -1925,7 +1875,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => {
const selection = await createFromPlaceholder(
row.sectionId,
session.cellKey,
editingCell.cellKey,
draft
);
targetSelection = selection;
@ -1942,7 +1892,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
redo: async () => {
await patchCircuit(row.circuit!.id, session.cellKey, next);
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
@ -1956,7 +1906,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
redo: async () => {
await patchDeviceRow(row.device!.id, session.cellKey, next);
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
@ -1971,7 +1921,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(session.cellKey, next)
buildDeviceRowEditPatch(editingCell.cellKey, next)
);
const snapshot = createDeviceRowSnapshot(
row.circuit!.id,
@ -1991,7 +1941,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
};
@ -2000,45 +1950,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
return Boolean(
target &&
target.cellKey === "simultaneityFactor" &&
findCell(target.rowKey, target.cellKey)?.editable
);
}
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
// inline edit is discarded because the picked value replaces it.
function openGzfPicker(target: SelectedCell) {
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
return;
}
setEditingCell(null);
setSelectedCell(target);
setGzfPickerCell(target);
}
function closeGzfPicker() {
setGzfPickerCell(null);
focusGridWithoutScroll();
}
// Commits the picked mean value like a typed edit (undoable command).
function handleGzfPicked(value: number) {
const target = gzfPickerCell;
setGzfPickerCell(null);
if (!target) {
return;
}
// parseNumeric accepts only the German decimal comma.
const draft = value.toLocaleString("de-DE", {
maximumFractionDigits: 2,
useGrouping: false,
});
void commitEdit("stay", draft, target);
}
// Cancels current edit session and restores grid keyboard focus.
function cancelEdit() {
if (!editingCell) {
@ -2982,14 +2893,6 @@ 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();
@ -3057,11 +2960,6 @@ 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);
@ -3097,35 +2995,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2}
label="Rückgängig"
<button
type="button"
onClick={() => void handleUndo()}
showLabel
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
icon={Redo2}
label="Wiederholen"
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
showLabel
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!Boolean(sortState) && !hasActiveFilters}
icon={FilterX}
label="Sortierung/Filter zurücksetzen"
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
>
Wiederholen
</button>
<button
type="button"
onClick={clearSortAndFilters}
showLabel
/>
disabled={!Boolean(sortState) && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
{renderColumnSettingsMenu("col-empty")}
</div>
{renderActiveViewSummary()}
@ -3229,9 +3122,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : null}
{gzfPickerCell ? (
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
@ -3250,90 +3140,82 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
/>
) : null}
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2}
label="Rückgängig"
<button
type="button"
onClick={() => void handleUndo()}
showLabel
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
icon={Redo2}
label="Wiederholen"
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
showLabel
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
/>
<IconButtonSeparator />
>
Wiederholen
</button>
{isSortedView ? (
<IconButton
disabled={hasActiveFilters || isSaving || historyBusy}
icon={Check}
label="Sortierung übernehmen"
<button
type="button"
onClick={() => void handleApplySortedOrder()}
showLabel
disabled={hasActiveFilters || isSaving || historyBusy}
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
/>
>
Sortierung übernehmen
</button>
) : null}
<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
<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"
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",
})
}
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
/>
>
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>
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
@ -3523,9 +3405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
>
<span className="header-label">
{renderColumnHeaderLabel(column.label)}
</span>
<span>{column.label}</span>
{sortState?.key === column.key ? (
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
{sortState.direction === "asc" ? "↑" : "↓"}
@ -3536,7 +3416,6 @@ 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
@ -3544,10 +3423,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: `${getFullColumnLabel(column)} filtern`
}
>
<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}
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
</button>
</div>
{openFilterColumn === column.key ? (
@ -3841,21 +3717,105 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span>
</div>
<div className="section-actions">
<IconButton
disabled={isSaving}
icon={Plus}
label="Stromkreis hinzufügen"
onClick={() => void handleAddReserveCircuit(section.id)}
<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}
disabled={isSaving}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
}
onClick={() =>
void handleDeleteCircuitGroup(section)
}
>
Gruppe entfernen
</button>
<button
type="button"
tabIndex={-1}
/>
<IconButton
disabled={
hasUpstreamProtection ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Vorsicherung hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3868,18 +3828,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
tabIndex={-1}
>
<span className="icon-button-text">VS</span>
</IconButton>
<IconButton
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={
hasGroupRcd ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Gruppen-FI hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3892,112 +3851,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
tabIndex={-1}
>
<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,
})
}
Gruppen-FI hinzufügen
</button>
<button
type="button"
tabIndex={-1}
/>
<IconButton
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
-1
)
}
icon={ArrowUp}
label="Gruppe nach oben"
onClick={() =>
void handleMoveCircuitGroup(section.id, -1)
}
tabIndex={-1}
title="Gruppe nach oben (innerhalb derselben Kategorie)"
/>
<IconButton
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
1
)
}
icon={ArrowDown}
label="Gruppe nach unten"
onClick={() =>
void handleMoveCircuitGroup(section.id, 1)
}
tabIndex={-1}
title="Gruppe nach unten (innerhalb derselben Kategorie)"
/>
<IconButtonSeparator />
<IconButton
disabled={hasActiveSortOrFilter || isSaving}
icon={ArrowDown01}
label="Bereich neu nummerieren"
onClick={() => void handleRenumberSection(section.id)}
tabIndex={-1}
title={
hasActiveSortOrFilter
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
: "Stromkreise dieser Gruppe neu nummerieren"
}
/>
<IconButton
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
data.sections,
section.category
)
}
icon={ListOrdered}
label="Gruppen-BMK neu nummerieren"
onClick={() =>
section.category
? void handleRenumberCircuitGroups(
section.category
)
: undefined
}
tabIndex={-1}
title={
hasActiveSortOrFilter
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
: "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
}
/>
<IconButtonSeparator />
<IconButton
disabled={isSaving}
icon={Trash2}
label="Gruppe entfernen"
onClick={() =>
void handleDeleteCircuitGroup(section)
}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen
</button>
<button
type="button"
tabIndex={-1}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
}
variant="danger"
/>
disabled={hasActiveSortOrFilter || isSaving}
onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
Bereich neu nummerieren
</button>
</div>
</div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
@ -4550,21 +4423,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} else if (event.key === "Tab") {
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();
@ -4572,27 +4435,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
/>
) : column.key === "simultaneityFactor" &&
cell.editable &&
isSelected ? (
<>
<button
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
className="cell-picker-trigger"
disabled={isSaving || historyBusy}
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}}
onDoubleClick={(event) => event.stopPropagation()}
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
type="button"
>
<TableProperties aria-hidden="true" size={13} />
</button>
{formatCellValue(cell.value, column.key)}
</>
) : (
formatCellValue(cell.value, column.key)
)}
@ -4625,32 +4467,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<IconButton
<button
type="button"
tabIndex={-1}
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)}
tabIndex={-1}
variant="danger"
/>
>
Stromkreis löschen
</button>
</>
) : null}
{row.device ? (
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">

View file

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

View file

@ -1,6 +1,5 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps {
@ -13,50 +12,14 @@ 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-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 aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0">
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p>

View file

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

View file

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

View file

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

View file

@ -28,14 +28,6 @@ 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;
@ -242,50 +234,50 @@ export function ProjectVersionHistory({
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<div className="overview-actions">
<IconButton
<button
className="btn btn-sm btn-outline-secondary"
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"
}
/>
<IconButton
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
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"
}
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isOpen}
icon={isOpen ? ChevronUp : ChevronDown}
label={isOpen ? "Ausblenden" : "Anzeigen"}
type="button"
>
Wiederholen
</button>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
onClick={() => setIsOpen((current) => !current)}
showLabel
/>
</div>
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
</div>
</div>
{error ? (
@ -389,15 +381,14 @@ export function ProjectVersionHistory({
</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
<button
className="btn btn-sm btn-outline-danger"
disabled={isBusy}
icon={RotateCcw}
label="Wiederherstellen"
onClick={() => setRestoreCandidate(snapshot)}
variant="danger"
/>
</div>
type="button"
>
Wiederherstellen
</button>
</td>
</tr>
))}

View file

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

View file

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

View file

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

View file

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