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

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

View file

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

View file

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

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

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 `data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar. angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung
mit Quellcode-Mounts und Hot Reload stattdessen
`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste
sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar.
Für direkte Node.js-Entwicklung: Für direkte Node.js-Entwicklung:
```powershell ```powershell
@ -91,8 +86,7 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
```powershell ```powershell
docker compose up --build --detach docker compose up --build --detach
docker compose ps docker compose ps
Invoke-WebRequest http://localhost:3001/health Invoke-WebRequest http://localhost:3000/health
Invoke-WebRequest http://localhost:3001/web-health
``` ```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich. Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.

View file

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

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -11,20 +10,10 @@ export default function CircuitTreeEditPage() {
return ( return (
<main className="container-fluid px-3 px-xxl-4 py-4"> <main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3"> <div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<Link
aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div> <div>
<h1 className="h4 mb-1">Stromkreisliste</h1> <h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p> <p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div> </div>
</div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm"> <Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt Zum Projekt

View file

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

View file

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

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

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

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"]
);
});
});