Compare commits
No commits in common. "3ffa50be4c4d3ba31cf203e5cc685eb25536dc99" and "2e3e85a8e52cfd1bd3434b9cc03af22bf164119d" have entirely different histories.
3ffa50be4c
...
2e3e85a8e5
20 changed files with 415 additions and 2304 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -164,53 +164,19 @@ Keyboard behavior:
|
|||
- Escape cancels
|
||||
- Tab / Shift+Tab navigates editable cells
|
||||
- Arrow keys navigate cells when not editing
|
||||
- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the
|
||||
selected GZF cell also shows a small table-icon button. The picked value is
|
||||
committed like a typed edit.
|
||||
|
||||
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
||||
|
||||
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
|
||||
`lucide-react` icons). The German action text is always the accessible name and
|
||||
tooltip; the toolbar shows icon plus text, group headers and rows show icons
|
||||
only, separated into logical blocks with `IconButtonSeparator`. Destructive
|
||||
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
|
||||
removing a device row).
|
||||
Column headers show the label (click to sort) with a funnel icon for the
|
||||
value filter; an active filter is highlighted and shows its value count.
|
||||
Header labels break only between words and put a trailing unit such as
|
||||
`[kW]` on a second line.
|
||||
The project overview uses the same rule: row actions are icon-only
|
||||
`IconButton`s, card-header and panel actions show icon plus text, navigation
|
||||
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
|
||||
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
|
||||
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
|
||||
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
|
||||
the bar collapses, its state is kept per card in `localStorage`, a collapsed
|
||||
active filter shows "Filter aktiv".
|
||||
|
||||
## Bootstrap / Styling Rule
|
||||
|
||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
||||
|
||||
Cards follow the PM app look: 1px border all around, 16px radius and the
|
||||
soft shadow defined on `.card` in `globals.css` (own dark-mode value). The
|
||||
`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other
|
||||
`shadow-*` classes or a colored top border to cards.
|
||||
|
||||
Do not use Bootstrap as the core table/grid framework for the circuit list editor.
|
||||
|
||||
The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior.
|
||||
|
||||
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
||||
|
||||
## Browser Runtime
|
||||
|
||||
The web UI is also opened over plain HTTP from other machines in the LAN, which
|
||||
is not a secure context. Do not call secure-context-only browser APIs such as
|
||||
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
|
||||
from `src/frontend/utils/client-id.ts`.
|
||||
|
||||
## Drag-and-Drop Rules
|
||||
|
||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -4,12 +4,10 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
|||
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||
|
||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
|
||||
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
||||
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||
implementiert.
|
||||
|
||||
## Unterstützter Arbeitsablauf
|
||||
|
||||
|
|
@ -27,9 +25,6 @@ Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
|||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
|
||||
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
|
||||
noch keine Stromkreise oder Gerätezeilen
|
||||
|
||||
## Technik
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
# Circuit List Editor Interactions
|
||||
|
||||
The back-arrow link beside the page title returns to the project's distribution
|
||||
board overview without changing circuit-list data.
|
||||
|
||||
## Editing Model
|
||||
|
||||
Inline cells are static text by default. A cell enters edit mode by:
|
||||
|
|
@ -47,7 +44,6 @@ Selection, keyboard movement, and editability checks run against this normalized
|
|||
- arrow keys: move selected cell in grid when not editing
|
||||
- type-to-edit: printable keys open edit mode and replace current display value with typed character
|
||||
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
|
||||
- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table
|
||||
- `Delete`: delete the selected circuit or device context after confirmation
|
||||
|
||||
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
|
||||
|
|
@ -56,23 +52,6 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
|
|||
|
||||
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
|
||||
|
||||
## AMEV Simultaneity Factor
|
||||
|
||||
The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open
|
||||
the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project
|
||||
device dialog uses as well. "Übernehmen" commits the mean value of the chosen
|
||||
consumer group through the same path as a typed edit (`commitEdit` with an
|
||||
explicit target cell), so it is one undoable command and works on device rows,
|
||||
compact circuits, reserve circuits and the `-frei-` row. A running inline edit
|
||||
of the cell is discarded when the picker opens. The button only appears on the
|
||||
selected cell; the grid keeps its static-text cells.
|
||||
|
||||
The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and
|
||||
closes on Escape without writing a value. Closing returns focus to the grid;
|
||||
only "Übernehmen" saves the selected mean value. Opening the picker is disabled
|
||||
while a write or history operation is running. Its cell button stays outside
|
||||
the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell.
|
||||
|
||||
## Equipment Identifier Validation
|
||||
|
||||
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
||||
|
|
@ -148,30 +127,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
|||
|
||||
## Filtering and Sorting
|
||||
|
||||
- Clicking a column label changes sorting; the funnel icon next to it opens
|
||||
the value filter. An active filter is highlighted and shows the number of
|
||||
selected values. Both controls retain German accessible names and tooltips.
|
||||
- Header labels wrap between words; trailing units such as `[kW]` appear on
|
||||
a separate line.
|
||||
- Per-column filtering works on normalized displayed values.
|
||||
- Sorting is view-level first and treats circuits as blocks.
|
||||
- Multi-device circuits are not split during sort.
|
||||
- Sorting alone does not persist order.
|
||||
|
||||
## Action Buttons
|
||||
|
||||
The editor toolbar uses icons with visible German labels. Group headers and
|
||||
circuit/device rows use compact icon buttons with German accessible names and
|
||||
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
|
||||
their roles. Dividers separate related actions; destructive actions use the
|
||||
danger style. Circuit deletion uses a trash icon, while device-row deletion
|
||||
uses a crossed-out list icon.
|
||||
|
||||
The shared `src/frontend/components/icon-button.tsx` component preserves
|
||||
disabled states and expanded states for toggles. Row and group action buttons
|
||||
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
|
||||
unchanged. The existing command handlers and deletion confirmations are reused.
|
||||
|
||||
## Apply Sorted Order
|
||||
|
||||
`Apply sorted order` persists current sorted block order to backend by section.
|
||||
|
|
@ -201,11 +161,6 @@ across multiple sections is atomic and occupies one history step. Explicit
|
|||
renumbering uses a collision-safe persistent project command and one
|
||||
project-history step.
|
||||
|
||||
The editor creates client-side UUIDs through `createClientId` in
|
||||
`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available
|
||||
and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion
|
||||
also works over plain HTTP from another machine in the LAN.
|
||||
|
||||
Covered operations include:
|
||||
|
||||
- insert/delete circuit
|
||||
|
|
|
|||
|
|
@ -27,18 +27,9 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
|||
data/leistungsbilanz.db (SQLite)
|
||||
```
|
||||
|
||||
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
||||
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
||||
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
|
||||
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
|
||||
Datenbank liegt über einen Host-Mount außerhalb des Containers.
|
||||
|
||||
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
|
||||
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
||||
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
||||
`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen
|
||||
auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen
|
||||
vor dem Command und bleiben über Undo/Redo unverändert.
|
||||
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
||||
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
||||
liegt über einen Host-Mount außerhalb des Containers.
|
||||
|
||||
## Wichtige Einstiegspunkte
|
||||
|
||||
|
|
@ -261,31 +252,6 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
|
|||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
|
||||
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
|
||||
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
|
||||
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
|
||||
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
|
||||
`IconButton`/`IconLink`.
|
||||
Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
|
||||
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
|
||||
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
|
||||
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
|
||||
Die Verteilersuche berücksichtigt aktuell Name und Etagenname; die Gerätesuche
|
||||
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
|
||||
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
|
||||
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
|
||||
und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende
|
||||
Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise
|
||||
„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei
|
||||
der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte.
|
||||
Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar.
|
||||
Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe
|
||||
und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine
|
||||
Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
|
||||
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
||||
bestehenden persistenten Projektbefehl.
|
||||
|
||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,9 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
|||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||
Entwicklungsrechner.
|
||||
|
||||
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
||||
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
||||
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
||||
deshalb nicht öffentlich erreichbar gemacht werden.
|
||||
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
||||
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
||||
erreichbar gemacht werden.
|
||||
|
||||
## Topologie
|
||||
|
||||
|
|
@ -35,11 +34,6 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
|
|||
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
||||
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||
|
||||
Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue
|
||||
UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das
|
||||
dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese
|
||||
ID-Erzeugung nicht erforderlich.
|
||||
|
||||
### Speichergrenzen
|
||||
|
||||
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
||||
|
|
@ -146,17 +140,14 @@ Vor einer produktiven Installation werden mindestens benötigt:
|
|||
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||
Mehrbenutzerbetrieb erforderlich ist
|
||||
|
||||
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
||||
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
|
||||
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
|
||||
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
||||
lokalen Entwicklungsstart in der README.
|
||||
|
||||
## Backup und Wiederherstellung
|
||||
|
||||
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
|
||||
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
|
||||
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
|
||||
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
|
||||
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
|
||||
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
|
||||
|
||||
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
|
||||
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
|
|||
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
||||
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
|
||||
|
||||
Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung
|
||||
mit Quellcode-Mounts und Hot Reload stattdessen
|
||||
`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste
|
||||
sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar.
|
||||
|
||||
Für direkte Node.js-Entwicklung:
|
||||
|
||||
```powershell
|
||||
|
|
@ -91,8 +86,7 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
|
|||
```powershell
|
||||
docker compose up --build --detach
|
||||
docker compose ps
|
||||
Invoke-WebRequest http://localhost:3001/health
|
||||
Invoke-WebRequest http://localhost:3001/web-health
|
||||
Invoke-WebRequest http://localhost:3000/health
|
||||
```
|
||||
|
||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||
|
|
|
|||
|
|
@ -381,263 +381,6 @@ a {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ─────
|
||||
Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen
|
||||
kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der
|
||||
PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an
|
||||
die Tokens dieser App. */
|
||||
|
||||
.btn-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.overview-actions .icon-button {
|
||||
min-width: 1.9rem;
|
||||
min-height: 1.85rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-actions .icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.overview-actions .icon-button-primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overview-actions .icon-button-primary:not(:disabled):hover {
|
||||
border-color: var(--color-primary-dark);
|
||||
background: var(--color-primary-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover {
|
||||
border-color: var(--color-accent-pale);
|
||||
background: var(--color-accent-pale);
|
||||
color: var(--color-primary-deep);
|
||||
}
|
||||
|
||||
.overview-actions .icon-button-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
padding: 0.55rem 1rem 0.7rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.filter-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-panel-header .filter-panel-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.filter-panel-header .filter-panel-toggle:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.filter-panel-label,
|
||||
.filter-bar-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.filter-panel-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #fbe9d9;
|
||||
color: var(--color-attention-dark);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .filter-panel-badge {
|
||||
background: #3a2e1c;
|
||||
color: #f0b880;
|
||||
}
|
||||
|
||||
.filter-panel-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-sm) var(--space-md);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar-field,
|
||||
.filter-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-bar-search,
|
||||
.filter-dropdown-toggle {
|
||||
min-height: 2.1rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.filter-bar-search {
|
||||
width: 15em;
|
||||
}
|
||||
|
||||
.filter-bar-search:focus,
|
||||
.filter-dropdown-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-dropdown-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 11em;
|
||||
max-width: 18em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-dropdown-toggle:hover,
|
||||
.filter-dropdown-toggle.is-active {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-dropdown-toggle.is-active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active {
|
||||
color: var(--color-accent-pale);
|
||||
}
|
||||
|
||||
.filter-dropdown-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-dropdown-toggle svg {
|
||||
flex: none;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.filter-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
box-shadow: 0 6px 20px var(--shadow-menu);
|
||||
}
|
||||
|
||||
.filter-dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-dropdown-option:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.filter-dropdown-option input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-dropdown-option-all {
|
||||
margin-bottom: 2px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.filter-dropdown-empty {
|
||||
padding: 6px 8px;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.filter-bar .filter-bar-reset {
|
||||
min-height: 2.1rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
||||
|
||||
.kpi-grid {
|
||||
|
|
@ -688,29 +431,12 @@ a.kpi:hover {
|
|||
color: var(--color-accent-pale);
|
||||
}
|
||||
|
||||
/* Flächen-Look wie in der PM-App (Vorbild grow-monitor): gleichmäßiger
|
||||
1-px-Rahmen statt farbigem Balken an der Oberkante, größerer Radius und ein
|
||||
weicher Streuschatten mit feiner heller Innenkante oben. Der Akzent kommt
|
||||
über Kopfzeile und Inhalt, nicht über die Kante. Bootstraps shadow-sm
|
||||
setzt box-shadow mit !important aus --bs-box-shadow-sm; die Karte legt diese
|
||||
Variable auf denselben Schatten, damit Karten mit und ohne shadow-sm gleich
|
||||
aussehen. */
|
||||
.card {
|
||||
--lb-card-shadow:
|
||||
0 4px 24px var(--shadow-soft),
|
||||
0 1px 0 rgba(255, 255, 255, 0.7) inset;
|
||||
--bs-box-shadow-sm: var(--lb-card-shadow);
|
||||
--bs-card-border-radius: 16px;
|
||||
--bs-card-border-color: var(--color-border);
|
||||
box-shadow: var(--lb-card-shadow);
|
||||
}
|
||||
|
||||
/* Auf dunklem Grund ein kräftigerer Streuschatten und nur eine schwache
|
||||
Innenkante, sonst wirkt sie wie ein harter weißer Strich. */
|
||||
:root[data-bs-theme="dark"] .card {
|
||||
--lb-card-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.4),
|
||||
0 1px 0 rgba(255, 255, 255, 0.05) inset;
|
||||
border-radius: var(--radius);
|
||||
border-top: 3px solid var(--color-primary);
|
||||
box-shadow:
|
||||
0 1px 2px var(--shadow-soft),
|
||||
0 10px 24px -14px var(--shadow-strong);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
|
|
@ -761,54 +487,6 @@ a.kpi:hover {
|
|||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Icon buttons (circuit list editor toolbar, group and row actions) */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-button:not(:disabled):hover {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.icon-button-danger:not(:disabled):hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.icon-button-text {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.icon-button-separator {
|
||||
align-self: stretch;
|
||||
width: 1px;
|
||||
margin: 0.1rem 0.15rem;
|
||||
background: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.editor-toolbar .project-device-drawer-toggle {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--accent-blue);
|
||||
|
|
@ -1166,37 +844,24 @@ a.kpi:hover {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* One header row: label (sort) on the left, filter icon on the right. */
|
||||
.tree-grid .header-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-btn {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.2rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-btn:hover,
|
||||
.tree-grid .header-filter-btn:focus-visible {
|
||||
border-color: var(--panel-border-strong);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-strong);
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn {
|
||||
|
|
@ -1214,10 +879,7 @@ a.kpi:hover {
|
|||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
/* Break only between words; never inside a word such as "Bemerkung". */
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
hyphens: manual;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -1257,18 +919,6 @@ a.kpi:hover {
|
|||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tree-grid .header-cell .header-sort-btn {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tree-grid .header-unit {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn:hover span:first-child,
|
||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||
text-decoration: underline;
|
||||
|
|
@ -1285,11 +935,6 @@ a.kpi:hover {
|
|||
background: var(--accent-blue-soft-bg);
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-count {
|
||||
min-width: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-menu {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
|
|
@ -1508,20 +1153,7 @@ a.kpi:hover {
|
|||
|
||||
.tree-grid .section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-actions .icon-button,
|
||||
.tree-grid .action-cell .icon-button {
|
||||
min-width: 1.75rem;
|
||||
min-height: 1.6rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-actions .icon-button:disabled,
|
||||
.tree-grid .action-cell .icon-button:disabled {
|
||||
opacity: 0.35;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-actions button {
|
||||
|
|
@ -1598,38 +1230,6 @@ a.kpi:hover {
|
|||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Picker trigger on the selected GZF cell. Positioned over the cell so the
|
||||
column width does not change while the selection moves. */
|
||||
.tree-grid td:has(> .cell-picker-trigger) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-grid .cell-picker-trigger {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.2rem;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border: 1px solid var(--accent-blue-strong-border);
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
background: var(--panel-bg);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .tree-grid .cell-picker-trigger {
|
||||
color: var(--color-accent-pale);
|
||||
}
|
||||
|
||||
.tree-grid .cell-picker-trigger:hover {
|
||||
background: var(--accent-blue-soft-bg);
|
||||
}
|
||||
|
||||
.tree-grid .section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -1665,7 +1265,7 @@ a.kpi:hover {
|
|||
|
||||
.tree-grid .action-cell {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tree-grid .action-cell button {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||
|
|
@ -11,19 +10,9 @@ export default function CircuitTreeEditPage() {
|
|||
return (
|
||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Link
|
||||
aria-label="Zurück zur Verteilerübersicht"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
href={`/projects/${params.projectId}`}
|
||||
title="Zurück zur Verteilerübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||
|
|
|
|||
|
|
@ -3,47 +3,6 @@
|
|||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileUp,
|
||||
Library,
|
||||
Link2,
|
||||
ListTree,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Unlink,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { IconButton } from "../../../frontend/components/icon-button";
|
||||
import { IconLink } from "../../../frontend/components/icon-link";
|
||||
import {
|
||||
CollapsibleFilterPanel,
|
||||
FilterSearchField,
|
||||
} from "../../../frontend/components/filter-panel";
|
||||
import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
circuitGroupCategoryLabels,
|
||||
} from "../../../shared/constants/circuit-group";
|
||||
import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board";
|
||||
import {
|
||||
buildCostGroupFilterOptions,
|
||||
buildFloorFilterOptions,
|
||||
buildSupplyTypeFilterOptions,
|
||||
emptyDistributionBoardFilters,
|
||||
emptyProjectDeviceFilters,
|
||||
filterDistributionBoards,
|
||||
filterProjectDevices,
|
||||
isDistributionBoardFilterActive,
|
||||
isProjectDeviceFilterActive,
|
||||
retainAvailableFilterValues,
|
||||
type DistributionBoardFilters,
|
||||
type FilterOption,
|
||||
type ProjectDeviceFilters,
|
||||
} from "../../../frontend/utils/overview-filters";
|
||||
import {
|
||||
copyGlobalDeviceToProject,
|
||||
copyDistributionBoard,
|
||||
|
|
@ -118,29 +77,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
|||
remark: "Bemerkung",
|
||||
};
|
||||
|
||||
const projectDeviceCategoryFilterOptions: FilterOption[] =
|
||||
circuitGroupCategories.map((category) => ({
|
||||
id: category,
|
||||
label: circuitGroupCategoryLabels[category],
|
||||
}));
|
||||
|
||||
const projectDevicePhaseTypeLabels: Record<ProjectDeviceDto["phaseType"], string> = {
|
||||
single_phase: "1-phasig",
|
||||
three_phase: "3-phasig",
|
||||
};
|
||||
|
||||
const projectDevicePhaseTypeFilterOptions: FilterOption[] = (
|
||||
["single_phase", "three_phase"] as const
|
||||
).map((phaseType) => ({
|
||||
id: phaseType,
|
||||
label: projectDevicePhaseTypeLabels[phaseType],
|
||||
}));
|
||||
|
||||
const boardFilterCollapsedStorageKey =
|
||||
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
|
||||
const projectDeviceFilterCollapsedStorageKey =
|
||||
"leistungsbilanz:overview-filter-collapsed:project-devices";
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
|
@ -177,11 +113,7 @@ export default function ProjectDetailPage() {
|
|||
useState(false);
|
||||
const [editingProjectDevice, setEditingProjectDevice] =
|
||||
useState<ProjectDeviceDto | null>(null);
|
||||
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
|
||||
emptyDistributionBoardFilters
|
||||
);
|
||||
const [projectDeviceFilters, setProjectDeviceFilters] =
|
||||
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
|
||||
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||
|
|
@ -232,67 +164,22 @@ export default function ProjectDetailPage() {
|
|||
const floorCount = useMemo(() => floors.length, [floors.length]);
|
||||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||||
const costGroupFilterOptions = useMemo(
|
||||
() => buildCostGroupFilterOptions(projectDevices),
|
||||
[projectDevices]
|
||||
);
|
||||
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
|
||||
() => ({
|
||||
...projectDeviceFilters,
|
||||
costGroups: retainAvailableFilterValues(
|
||||
projectDeviceFilters.costGroups,
|
||||
costGroupFilterOptions
|
||||
),
|
||||
}),
|
||||
[costGroupFilterOptions, projectDeviceFilters]
|
||||
);
|
||||
const visibleProjectDevices = useMemo(
|
||||
() =>
|
||||
filterProjectDevices(
|
||||
projectDevices,
|
||||
effectiveProjectDeviceFilters,
|
||||
circuitGroupCategoryLabels
|
||||
),
|
||||
[effectiveProjectDeviceFilters, projectDevices]
|
||||
);
|
||||
const visibleProjectDevices = useMemo(() => {
|
||||
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
||||
if (!query) {
|
||||
return projectDevices;
|
||||
}
|
||||
return projectDevices.filter((device) =>
|
||||
[
|
||||
device.name,
|
||||
device.displayName,
|
||||
device.category,
|
||||
device.connectionKind,
|
||||
device.costGroup,
|
||||
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
||||
);
|
||||
}, [projectDeviceQuery, projectDevices]);
|
||||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
||||
const floorFilterOptions = useMemo(
|
||||
() => buildFloorFilterOptions(floors),
|
||||
[floors]
|
||||
);
|
||||
const supplyTypeFilterOptions = useMemo(
|
||||
() =>
|
||||
buildSupplyTypeFilterOptions(
|
||||
distributionBoardSupplyTypes,
|
||||
project?.enabledDistributionBoardSupplyTypes ?? [],
|
||||
boards,
|
||||
distributionBoardSupplyTypeLabels
|
||||
),
|
||||
[boards, project?.enabledDistributionBoardSupplyTypes]
|
||||
);
|
||||
const effectiveBoardFilters = useMemo<DistributionBoardFilters>(
|
||||
() => ({
|
||||
...boardFilters,
|
||||
floorIds: retainAvailableFilterValues(
|
||||
boardFilters.floorIds,
|
||||
floorFilterOptions
|
||||
),
|
||||
supplyTypes: retainAvailableFilterValues(
|
||||
boardFilters.supplyTypes,
|
||||
supplyTypeFilterOptions
|
||||
),
|
||||
}),
|
||||
[boardFilters, floorFilterOptions, supplyTypeFilterOptions]
|
||||
);
|
||||
const visibleBoards = useMemo(
|
||||
() =>
|
||||
filterDistributionBoards(
|
||||
boards,
|
||||
effectiveBoardFilters,
|
||||
new Map(floors.map((floor) => [floor.id, floor.name]))
|
||||
),
|
||||
[boards, effectiveBoardFilters, floors]
|
||||
);
|
||||
const circuitListByBoardId = useMemo(
|
||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||
[circuitLists]
|
||||
|
|
@ -476,11 +363,6 @@ export default function ProjectDetailPage() {
|
|||
if (!projectId || !project) {
|
||||
return;
|
||||
}
|
||||
const device = projectDevices.find((item) => item.id === projectDeviceId);
|
||||
const deviceName = device?.displayName || device?.name || "Projektgerät";
|
||||
if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -968,28 +850,22 @@ export default function ProjectDetailPage() {
|
|||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-with-icon"
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
onClick={() => setIsRevitCsvOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
|
||||
Revit-CSV
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-with-icon"
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
onClick={() => setIsProjectSettingsOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Settings aria-hidden="true" size={16} strokeWidth={2} />
|
||||
Projekteinstellungen
|
||||
</button>
|
||||
<Link
|
||||
className="btn btn-outline-secondary btn-with-icon"
|
||||
href="/projects"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
|
||||
<Link className="btn btn-outline-secondary" href="/projects">
|
||||
Zur Projektübersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -1034,52 +910,14 @@ export default function ProjectDetailPage() {
|
|||
<span>Verteilungen</span>
|
||||
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
icon={Plus}
|
||||
label="Verteilung hinzufügen"
|
||||
onClick={openBoardCreator}
|
||||
showLabel
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={openBoardCreator}
|
||||
type="button"
|
||||
>
|
||||
Verteilung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<CollapsibleFilterPanel
|
||||
isFilterActive={isDistributionBoardFilterActive(
|
||||
effectiveBoardFilters
|
||||
)}
|
||||
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
|
||||
storageKey={boardFilterCollapsedStorageKey}
|
||||
totalCount={boards.length}
|
||||
visibleCount={visibleBoards.length}
|
||||
>
|
||||
<FilterSearchField
|
||||
label="Suche"
|
||||
onChange={(query) =>
|
||||
setBoardFilters((current) => ({ ...current, query }))
|
||||
}
|
||||
placeholder="Verteilungen durchsuchen …"
|
||||
value={boardFilters.query}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
emptyLabel="Keine Etagen vorhanden"
|
||||
label="Etage"
|
||||
onChange={(floorIds) =>
|
||||
setBoardFilters((current) => ({ ...current, floorIds }))
|
||||
}
|
||||
options={floorFilterOptions}
|
||||
selected={effectiveBoardFilters.floorIds}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
emptyLabel="Keine Netzarten vorhanden"
|
||||
label="Netzart"
|
||||
onChange={(supplyTypes) =>
|
||||
setBoardFilters((current) => ({ ...current, supplyTypes }))
|
||||
}
|
||||
options={supplyTypeFilterOptions}
|
||||
selected={effectiveBoardFilters.supplyTypes}
|
||||
/>
|
||||
</CollapsibleFilterPanel>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
|
|
@ -1092,7 +930,7 @@ export default function ProjectDetailPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleBoards.map((board) => {
|
||||
{boards.map((board) => {
|
||||
const circuitList = circuitListByBoardId.get(board.id);
|
||||
return (
|
||||
<tr key={board.id}>
|
||||
|
|
@ -1119,21 +957,22 @@ export default function ProjectDetailPage() {
|
|||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="overview-actions justify-content-end">
|
||||
<IconButton
|
||||
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={isSaving}
|
||||
icon={Pencil}
|
||||
label="Bearbeiten"
|
||||
onClick={() => openBoardEditor(board)}
|
||||
/>
|
||||
type="button"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{circuitList ? (
|
||||
<IconLink
|
||||
<Link
|
||||
className="btn btn-sm btn-primary"
|
||||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||||
icon={ListTree}
|
||||
label="Stromkreisliste öffnen"
|
||||
showLabel
|
||||
variant="primary"
|
||||
/>
|
||||
>
|
||||
Stromkreisliste öffnen
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-secondary small align-self-center">
|
||||
Keine Stromkreisliste vorhanden
|
||||
|
|
@ -1153,11 +992,6 @@ export default function ProjectDetailPage() {
|
|||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{boards.length > 0 && visibleBoards.length === 0 ? (
|
||||
<p className="text-center text-secondary py-4 mb-0">
|
||||
Keine Verteilungen entsprechen den Filtern.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1251,58 +1085,24 @@ export default function ProjectDetailPage() {
|
|||
<span>Projektgeräte</span>
|
||||
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
icon={Plus}
|
||||
label="Gerät hinzufügen"
|
||||
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||||
showLabel
|
||||
variant="primary"
|
||||
<div className="d-flex gap-2">
|
||||
<input
|
||||
aria-label="Projektgeräte durchsuchen"
|
||||
className="form-control form-control-sm"
|
||||
onChange={(event) => setProjectDeviceQuery(event.target.value)}
|
||||
placeholder="Geräte durchsuchen"
|
||||
type="search"
|
||||
value={projectDeviceQuery}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary text-nowrap"
|
||||
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
Gerät hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleFilterPanel
|
||||
isFilterActive={isProjectDeviceFilterActive(
|
||||
effectiveProjectDeviceFilters
|
||||
)}
|
||||
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
|
||||
storageKey={projectDeviceFilterCollapsedStorageKey}
|
||||
totalCount={projectDevices.length}
|
||||
visibleCount={visibleProjectDevices.length}
|
||||
>
|
||||
<FilterSearchField
|
||||
label="Suche"
|
||||
onChange={(query) =>
|
||||
setProjectDeviceFilters((current) => ({ ...current, query }))
|
||||
}
|
||||
placeholder="Gerät, Anschlussart, KG …"
|
||||
value={projectDeviceFilters.query}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
label="Kategorie"
|
||||
onChange={(categories) =>
|
||||
setProjectDeviceFilters((current) => ({ ...current, categories }))
|
||||
}
|
||||
options={projectDeviceCategoryFilterOptions}
|
||||
selected={effectiveProjectDeviceFilters.categories}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
label="Anschluss"
|
||||
onChange={(phaseTypes) =>
|
||||
setProjectDeviceFilters((current) => ({ ...current, phaseTypes }))
|
||||
}
|
||||
options={projectDevicePhaseTypeFilterOptions}
|
||||
selected={effectiveProjectDeviceFilters.phaseTypes}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
label="Kostengruppe"
|
||||
onChange={(costGroups) =>
|
||||
setProjectDeviceFilters((current) => ({ ...current, costGroups }))
|
||||
}
|
||||
options={costGroupFilterOptions}
|
||||
selected={effectiveProjectDeviceFilters.costGroups}
|
||||
/>
|
||||
</CollapsibleFilterPanel>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
|
|
@ -1321,13 +1121,11 @@ export default function ProjectDetailPage() {
|
|||
<strong>{device.displayName}</strong>
|
||||
<div className="small text-secondary">
|
||||
{device.name}
|
||||
{device.category
|
||||
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
|
||||
: ""}
|
||||
{device.category ? ` · ${device.category}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{projectDevicePhaseTypeLabels[device.phaseType]}
|
||||
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
||||
{device.connectionKind ? (
|
||||
<div className="small text-secondary">
|
||||
{device.connectionKind}
|
||||
|
|
@ -1343,32 +1141,39 @@ export default function ProjectDetailPage() {
|
|||
</td>
|
||||
<td>{device.costGroup ?? "-"}</td>
|
||||
<td className="text-end">
|
||||
<div className="overview-actions justify-content-end">
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Pencil}
|
||||
label="Bearbeiten"
|
||||
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
type="button"
|
||||
onClick={() => setEditingProjectDevice(device)}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Link2}
|
||||
label="Verknüpfungen"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Library}
|
||||
label="Nach global kopieren"
|
||||
onClick={() => void handleCopyProjectToGlobal(device.id)}
|
||||
/>
|
||||
<IconButton
|
||||
>
|
||||
Verknüpfungen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
type="button"
|
||||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
||||
disabled={isSaving}
|
||||
icon={Trash2}
|
||||
label="Löschen"
|
||||
onClick={() => void handleDeleteProjectDevice(device.id)}
|
||||
variant="danger"
|
||||
/>
|
||||
>
|
||||
Nach global kopieren
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
type="button"
|
||||
onClick={() => handleDeleteProjectDevice(device.id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1377,7 +1182,7 @@ export default function ProjectDetailPage() {
|
|||
<tr>
|
||||
<td colSpan={5} className="text-center text-secondary py-4">
|
||||
{projectDevices.length
|
||||
? "Keine Projektgeräte entsprechen den Filtern."
|
||||
? "Keine Projektgeräte entsprechen der Suche."
|
||||
: "Noch keine Projektgeräte vorhanden."}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1394,13 +1199,9 @@ export default function ProjectDetailPage() {
|
|||
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
icon={X}
|
||||
label="Schließen"
|
||||
onClick={() => setSyncPreview(null)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-wrap gap-3 mb-3">
|
||||
|
|
@ -1479,23 +1280,23 @@ export default function ProjectDetailPage() {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||
icon={RefreshCw}
|
||||
label="Auswahl synchronisieren"
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||||
showLabel
|
||||
variant="primary"
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||
icon={Unlink}
|
||||
label="Auswahl trennen"
|
||||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||
>
|
||||
Auswahl synchronisieren
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
type="button"
|
||||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||||
showLabel
|
||||
variant="danger"
|
||||
/>
|
||||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||
>
|
||||
Auswahl trennen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDown01,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Columns3,
|
||||
FilterX,
|
||||
FolderPlus,
|
||||
Funnel,
|
||||
ListOrdered,
|
||||
ListPlus,
|
||||
ListX,
|
||||
PackagePlus,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Pencil,
|
||||
Plus,
|
||||
Redo2,
|
||||
ShieldPlus,
|
||||
TableProperties,
|
||||
Trash2,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||
import {
|
||||
inferProjectDeviceSectionKey,
|
||||
isProjectDevicePlacementValid,
|
||||
|
|
@ -100,7 +76,6 @@ import {
|
|||
type MutableDistributionBoardComponentRole,
|
||||
} from "../utils/distribution-board-component-editing";
|
||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
import type {
|
||||
CellKey,
|
||||
ColumnDef,
|
||||
|
|
@ -160,7 +135,6 @@ import type {
|
|||
CircuitSnapshot,
|
||||
} from "../../domain/models/circuit-structure-project-command.model";
|
||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||
import { GzfPickerModal } from "./gzf-picker-modal";
|
||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||
import {
|
||||
|
|
@ -272,24 +246,6 @@ function createValuesFromEditPatch(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header labels break only between words: hyphens become non-breaking and a
|
||||
* trailing unit such as "[kW]" is always shown on its own second line, so
|
||||
* header heights stay uniform.
|
||||
*/
|
||||
function renderColumnHeaderLabel(label: string) {
|
||||
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
|
||||
const name = (match ? match[1] : label).replace(/-/g, "‑");
|
||||
return match ? (
|
||||
<>
|
||||
{name}
|
||||
<span className="header-unit">{match[2]}</span>
|
||||
</>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
||||
/*
|
||||
Circuit-tree editor invariant overview:
|
||||
|
|
@ -320,8 +276,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
useState<StructureComponentEditorIntent | null>(null);
|
||||
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
||||
useState<CircuitGroupEditorIntent | null>(null);
|
||||
// GZF cell the AMEV picker writes to; null while the picker is closed.
|
||||
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
|
||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||
useState<CircuitTreeCircuitDto | null>(null);
|
||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||
|
|
@ -1023,7 +977,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
defaultSortOrder: number
|
||||
) {
|
||||
return buildCircuitDeviceRowInsertSnapshot({
|
||||
id: createClientId(),
|
||||
id: crypto.randomUUID(),
|
||||
circuitId,
|
||||
values,
|
||||
defaultSortOrder,
|
||||
|
|
@ -1047,7 +1001,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
),
|
||||
data
|
||||
);
|
||||
const circuitId = createClientId();
|
||||
const circuitId = crypto.randomUUID();
|
||||
const deviceRows = deviceRowValues.map((row, index) =>
|
||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||
);
|
||||
|
|
@ -1215,7 +1169,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
(section) => section.id === intent.sectionId
|
||||
)?.components ?? [];
|
||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||
id: createClientId(),
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId,
|
||||
role: intent.role,
|
||||
sectionId: intent.sectionId,
|
||||
|
|
@ -1280,7 +1234,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
buildNewCircuitGroupSnapshot({
|
||||
id: createClientId(),
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId,
|
||||
category: values.category,
|
||||
displayName: values.displayName,
|
||||
|
|
@ -1875,26 +1829,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
}
|
||||
|
||||
// Commits the active editor input through command history so edits remain undoable.
|
||||
// `target` commits a value for a cell without an open inline editor
|
||||
// (AMEV GZF picker); the regular edit path passes no target.
|
||||
async function commitEdit(
|
||||
direction: SaveDirection = "stay",
|
||||
draftOverride?: string,
|
||||
target?: SelectedCell
|
||||
draftOverride?: string
|
||||
) {
|
||||
const session = target ?? editingCell;
|
||||
if (!session) {
|
||||
if (!editingCell) {
|
||||
return;
|
||||
}
|
||||
const row = findRow(session.rowKey);
|
||||
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
|
||||
const row = findRow(editingCell.rowKey);
|
||||
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
||||
if (!row || !cell || !cell.editable) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
const draft = draftOverride ?? editingCell?.draft ?? "";
|
||||
const draft = draftOverride ?? editingCell.draft;
|
||||
if (
|
||||
session.cellKey === "equipmentIdentifier" &&
|
||||
editingCell.cellKey === "equipmentIdentifier" &&
|
||||
row.circuit &&
|
||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||
) {
|
||||
|
|
@ -1902,7 +1852,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
return;
|
||||
}
|
||||
|
||||
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
|
||||
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
||||
const nextSelectionIntent = () => {
|
||||
let selected = targetSelection;
|
||||
if (direction !== "stay") {
|
||||
|
|
@ -1925,7 +1875,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
redo: async () => {
|
||||
const selection = await createFromPlaceholder(
|
||||
row.sectionId,
|
||||
session.cellKey,
|
||||
editingCell.cellKey,
|
||||
draft
|
||||
);
|
||||
targetSelection = selection;
|
||||
|
|
@ -1942,7 +1892,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const command: HistoryCommand = {
|
||||
label: "Stromkreisfeld bearbeiten",
|
||||
redo: async () => {
|
||||
await patchCircuit(row.circuit!.id, session.cellKey, next);
|
||||
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -1956,7 +1906,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const command: HistoryCommand = {
|
||||
label: "Gerätefeld bearbeiten",
|
||||
redo: async () => {
|
||||
await patchDeviceRow(row.device!.id, session.cellKey, next);
|
||||
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -1971,7 +1921,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||
redo: async () => {
|
||||
const editValues = createValuesFromEditPatch(
|
||||
buildDeviceRowEditPatch(session.cellKey, next)
|
||||
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
||||
);
|
||||
const snapshot = createDeviceRowSnapshot(
|
||||
row.circuit!.id,
|
||||
|
|
@ -1991,7 +1941,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
snapshot,
|
||||
"Gerätezeile im Reservestromkreis erstellen"
|
||||
);
|
||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
|
||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -2000,45 +1950,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
}
|
||||
}
|
||||
|
||||
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
|
||||
return Boolean(
|
||||
target &&
|
||||
target.cellKey === "simultaneityFactor" &&
|
||||
findCell(target.rowKey, target.cellKey)?.editable
|
||||
);
|
||||
}
|
||||
|
||||
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
|
||||
// inline edit is discarded because the picked value replaces it.
|
||||
function openGzfPicker(target: SelectedCell) {
|
||||
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
|
||||
return;
|
||||
}
|
||||
setEditingCell(null);
|
||||
setSelectedCell(target);
|
||||
setGzfPickerCell(target);
|
||||
}
|
||||
|
||||
function closeGzfPicker() {
|
||||
setGzfPickerCell(null);
|
||||
focusGridWithoutScroll();
|
||||
}
|
||||
|
||||
// Commits the picked mean value like a typed edit (undoable command).
|
||||
function handleGzfPicked(value: number) {
|
||||
const target = gzfPickerCell;
|
||||
setGzfPickerCell(null);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
// parseNumeric accepts only the German decimal comma.
|
||||
const draft = value.toLocaleString("de-DE", {
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
void commitEdit("stay", draft, target);
|
||||
}
|
||||
|
||||
// Cancels current edit session and restores grid keyboard focus.
|
||||
function cancelEdit() {
|
||||
if (!editingCell) {
|
||||
|
|
@ -2982,14 +2893,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
// - printable key starts overwrite edit (type-to-edit)
|
||||
// - Arrow/Tab move selectedCell when not editing
|
||||
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
// The picker modal sits outside the grid; keep grid keys from acting behind it.
|
||||
if (gzfPickerCell) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeGzfPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editingCell) {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
|
|
@ -3057,11 +2960,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
moveHorizontal(-1);
|
||||
return;
|
||||
}
|
||||
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
|
||||
event.preventDefault();
|
||||
openGzfPicker(selectedCell);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveVertical(1);
|
||||
|
|
@ -3097,37 +2995,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
return (
|
||||
<div className="tree-editor-shell">
|
||||
<div className="editor-toolbar">
|
||||
<IconButton
|
||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||
icon={Undo2}
|
||||
label="Rückgängig"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleUndo()}
|
||||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||
icon={Redo2}
|
||||
label="Wiederholen"
|
||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedo()}
|
||||
showLabel
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
ariaExpanded={isColumnMenuOpen}
|
||||
icon={Columns3}
|
||||
label="Spalten"
|
||||
onClick={toggleColumnSettingsMenu}
|
||||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||
icon={FilterX}
|
||||
label="Sortierung/Filter zurücksetzen"
|
||||
onClick={clearSortAndFilters}
|
||||
showLabel
|
||||
/>
|
||||
{renderColumnSettingsMenu("col-empty")}
|
||||
</div>
|
||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||
>
|
||||
Wiederholen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSortAndFilters}
|
||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||
>
|
||||
Sortierung/Filter zurücksetzen
|
||||
</button>
|
||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||
Spalten
|
||||
</button>
|
||||
{renderColumnSettingsMenu("col-empty")}
|
||||
</div>
|
||||
{renderActiveViewSummary()}
|
||||
{renderDistributionPowerSummary()}
|
||||
{error ? (
|
||||
|
|
@ -3229,9 +3122,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
onSave={handleSaveCircuitGroup}
|
||||
/>
|
||||
) : null}
|
||||
{gzfPickerCell ? (
|
||||
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
|
||||
) : null}
|
||||
{protectionEditorCircuit ? (
|
||||
<CircuitProtectionModal
|
||||
equipmentIdentifier={
|
||||
|
|
@ -3250,90 +3140,82 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
/>
|
||||
) : null}
|
||||
<div className="editor-toolbar">
|
||||
<IconButton
|
||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||
icon={Undo2}
|
||||
label="Rückgängig"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleUndo()}
|
||||
showLabel
|
||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||
title={
|
||||
historyState?.undoDepth
|
||||
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||
icon={Redo2}
|
||||
label="Wiederholen"
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedo()}
|
||||
showLabel
|
||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||
title={
|
||||
historyState?.redoDepth
|
||||
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
>
|
||||
Wiederholen
|
||||
</button>
|
||||
{isSortedView ? (
|
||||
<IconButton
|
||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||
icon={Check}
|
||||
label="Sortierung übernehmen"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApplySortedOrder()}
|
||||
showLabel
|
||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
||||
/>
|
||||
>
|
||||
Sortierung übernehmen
|
||||
</button>
|
||||
) : null}
|
||||
<IconButton
|
||||
ariaExpanded={isColumnMenuOpen}
|
||||
icon={Columns3}
|
||||
label="Spalten"
|
||||
onClick={toggleColumnSettingsMenu}
|
||||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!isSortedView && !hasActiveFilters}
|
||||
icon={FilterX}
|
||||
label="Sortierung/Filter zurücksetzen"
|
||||
onClick={clearSortAndFilters}
|
||||
showLabel
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||
Spalten
|
||||
</button>
|
||||
<button
|
||||
aria-controls="project-device-drawer"
|
||||
aria-expanded={isProjectDeviceDrawerOpen}
|
||||
className="project-device-drawer-toggle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||
}
|
||||
>
|
||||
{isProjectDeviceDrawerOpen
|
||||
? "Projektgeräte schließen"
|
||||
: `Projektgeräte öffnen (${projectDevices.length})`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving || historyBusy}
|
||||
icon={FolderPlus}
|
||||
label="Stromkreisgruppe hinzufügen"
|
||||
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isSaving || historyBusy}
|
||||
icon={PackagePlus}
|
||||
label="Verteilergerät hinzufügen"
|
||||
onClick={() =>
|
||||
setComponentEditorIntent({
|
||||
kind: "create",
|
||||
role: "auxiliary",
|
||||
})
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
ariaControls="project-device-drawer"
|
||||
ariaExpanded={isProjectDeviceDrawerOpen}
|
||||
className="project-device-drawer-toggle"
|
||||
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
|
||||
label={
|
||||
isProjectDeviceDrawerOpen
|
||||
? "Projektgeräte schließen"
|
||||
: `Projektgeräte öffnen (${projectDevices.length})`
|
||||
}
|
||||
onClick={() =>
|
||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
>
|
||||
Verteilergerät hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving || historyBusy}
|
||||
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||
>
|
||||
Stromkreisgruppe hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSortAndFilters}
|
||||
disabled={!isSortedView && !hasActiveFilters}
|
||||
>
|
||||
Sortierung/Filter zurücksetzen
|
||||
</button>
|
||||
{renderColumnSettingsMenu("col")}
|
||||
</div>
|
||||
{renderActiveViewSummary()}
|
||||
|
|
@ -3523,9 +3405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
});
|
||||
}}
|
||||
>
|
||||
<span className="header-label">
|
||||
{renderColumnHeaderLabel(column.label)}
|
||||
</span>
|
||||
<span>{column.label}</span>
|
||||
{sortState?.key === column.key ? (
|
||||
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
|
||||
{sortState.direction === "asc" ? "↑" : "↓"}
|
||||
|
|
@ -3536,7 +3416,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
type="button"
|
||||
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
||||
onClick={() => toggleColumnFilterMenu(column.key)}
|
||||
aria-label={`${getFullColumnLabel(column)} filtern`}
|
||||
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
|
||||
title={
|
||||
(columnFilters[column.key]?.length ?? 0) > 0
|
||||
|
|
@ -3544,10 +3423,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
: `${getFullColumnLabel(column)} filtern`
|
||||
}
|
||||
>
|
||||
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||
{(columnFilters[column.key]?.length ?? 0) > 0 ? (
|
||||
<span className="header-filter-count">{columnFilters[column.key]?.length}</span>
|
||||
) : null}
|
||||
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
{openFilterColumn === column.key ? (
|
||||
|
|
@ -3841,21 +3717,105 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
</span>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Plus}
|
||||
label="Stromkreis hinzufügen"
|
||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={!section.category || !section.groupNumber}
|
||||
onClick={() =>
|
||||
setCircuitGroupEditorIntent({
|
||||
kind: "edit",
|
||||
section,
|
||||
})
|
||||
}
|
||||
>
|
||||
Gruppe bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
-1
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
void handleMoveCircuitGroup(section.id, -1)
|
||||
}
|
||||
title="Innerhalb derselben Kategorie nach oben verschieben"
|
||||
>
|
||||
Gruppe nach oben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
1
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
void handleMoveCircuitGroup(section.id, 1)
|
||||
}
|
||||
title="Innerhalb derselben Kategorie nach unten verschieben"
|
||||
>
|
||||
Gruppe nach unten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
hasActiveSortOrFilter ||
|
||||
!section.category ||
|
||||
!canRenumberCircuitGroups(
|
||||
data.sections,
|
||||
section.category
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
section.category
|
||||
? void handleRenumberCircuitGroups(
|
||||
section.category
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
hasActiveSortOrFilter
|
||||
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
||||
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
||||
}
|
||||
>
|
||||
Gruppen-BMK neu nummerieren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
title={
|
||||
canDeleteCircuitGroup(section)
|
||||
? "Leere Stromkreisgruppe entfernen"
|
||||
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
|
||||
}
|
||||
onClick={() =>
|
||||
void handleDeleteCircuitGroup(section)
|
||||
}
|
||||
>
|
||||
Gruppe entfernen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
hasUpstreamProtection ||
|
||||
!section.category ||
|
||||
!section.groupNumber
|
||||
}
|
||||
icon={ShieldPlus}
|
||||
label="Vorsicherung hinzufügen"
|
||||
onClick={() =>
|
||||
setComponentEditorIntent({
|
||||
kind: "create",
|
||||
|
|
@ -3868,18 +3828,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
),
|
||||
})
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="icon-button-text">VS</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
Vorsicherung hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
hasGroupRcd ||
|
||||
!section.category ||
|
||||
!section.groupNumber
|
||||
}
|
||||
icon={ShieldPlus}
|
||||
label="Gruppen-FI hinzufügen"
|
||||
onClick={() =>
|
||||
setComponentEditorIntent({
|
||||
kind: "create",
|
||||
|
|
@ -3892,112 +3851,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
),
|
||||
})
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="icon-button-text">FI</span>
|
||||
</IconButton>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
disabled={!section.category || !section.groupNumber}
|
||||
icon={Pencil}
|
||||
label="Gruppe bearbeiten"
|
||||
onClick={() =>
|
||||
setCircuitGroupEditorIntent({
|
||||
kind: "edit",
|
||||
section,
|
||||
})
|
||||
}
|
||||
Gruppen-FI hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
-1
|
||||
)
|
||||
}
|
||||
icon={ArrowUp}
|
||||
label="Gruppe nach oben"
|
||||
onClick={() =>
|
||||
void handleMoveCircuitGroup(section.id, -1)
|
||||
}
|
||||
tabIndex={-1}
|
||||
title="Gruppe nach oben (innerhalb derselben Kategorie)"
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
1
|
||||
)
|
||||
}
|
||||
icon={ArrowDown}
|
||||
label="Gruppe nach unten"
|
||||
onClick={() =>
|
||||
void handleMoveCircuitGroup(section.id, 1)
|
||||
}
|
||||
tabIndex={-1}
|
||||
title="Gruppe nach unten (innerhalb derselben Kategorie)"
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
disabled={hasActiveSortOrFilter || isSaving}
|
||||
icon={ArrowDown01}
|
||||
label="Bereich neu nummerieren"
|
||||
onClick={() => void handleRenumberSection(section.id)}
|
||||
tabIndex={-1}
|
||||
title={
|
||||
hasActiveSortOrFilter
|
||||
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
|
||||
: "Stromkreise dieser Gruppe neu nummerieren"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isSaving ||
|
||||
hasActiveSortOrFilter ||
|
||||
!section.category ||
|
||||
!canRenumberCircuitGroups(
|
||||
data.sections,
|
||||
section.category
|
||||
)
|
||||
}
|
||||
icon={ListOrdered}
|
||||
label="Gruppen-BMK neu nummerieren"
|
||||
onClick={() =>
|
||||
section.category
|
||||
? void handleRenumberCircuitGroups(
|
||||
section.category
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
tabIndex={-1}
|
||||
title={
|
||||
hasActiveSortOrFilter
|
||||
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
||||
: "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
||||
}
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Trash2}
|
||||
label="Gruppe entfernen"
|
||||
onClick={() =>
|
||||
void handleDeleteCircuitGroup(section)
|
||||
}
|
||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||
>
|
||||
Stromkreis hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
title={
|
||||
canDeleteCircuitGroup(section)
|
||||
? "Leere Stromkreisgruppe entfernen"
|
||||
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
|
||||
}
|
||||
variant="danger"
|
||||
/>
|
||||
disabled={hasActiveSortOrFilter || isSaving}
|
||||
onClick={() => void handleRenumberSection(section.id)}
|
||||
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
||||
>
|
||||
Bereich neu nummerieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||
|
|
@ -4550,21 +4423,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
void commitEdit(event.shiftKey ? "prev" : "next");
|
||||
} else if (
|
||||
event.altKey &&
|
||||
event.key === "ArrowDown" &&
|
||||
column.key === "simultaneityFactor"
|
||||
) {
|
||||
event.preventDefault();
|
||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
|
||||
return;
|
||||
}
|
||||
if (!active || !containerRef.current?.contains(active)) {
|
||||
setEditingCell(null);
|
||||
focusGridWithoutScroll();
|
||||
|
|
@ -4572,27 +4435,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
});
|
||||
}}
|
||||
/>
|
||||
) : column.key === "simultaneityFactor" &&
|
||||
cell.editable &&
|
||||
isSelected ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
className="cell-picker-trigger"
|
||||
disabled={isSaving || historyBusy}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
||||
}}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
|
||||
type="button"
|
||||
>
|
||||
<TableProperties aria-hidden="true" size={13} />
|
||||
</button>
|
||||
{formatCellValue(cell.value, column.key)}
|
||||
</>
|
||||
) : (
|
||||
formatCellValue(cell.value, column.key)
|
||||
)}
|
||||
|
|
@ -4625,32 +4467,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
>
|
||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||
<>
|
||||
<IconButton
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
icon={ListPlus}
|
||||
label="Manuelles Gerät hinzufügen"
|
||||
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
||||
>
|
||||
Manuelles Gerät hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Trash2}
|
||||
label="Stromkreis löschen"
|
||||
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
||||
tabIndex={-1}
|
||||
variant="danger"
|
||||
/>
|
||||
>
|
||||
Stromkreis löschen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{row.device ? (
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={ListX}
|
||||
label="Gerät löschen"
|
||||
onClick={() => void handleDeleteDevice(row.device!.id)}
|
||||
tabIndex={-1}
|
||||
variant="danger"
|
||||
/>
|
||||
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||
Gerät löschen
|
||||
</button>
|
||||
) : null}
|
||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||
<span className="drop-hint">
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
|
||||
import React, { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { IconButton } from "./icon-button";
|
||||
|
||||
function readCollapsed(storageKey: string) {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// Storage unavailable (private mode, blocked): keep the in-memory state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible filter area inside a card (PM app style): a small header row
|
||||
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
|
||||
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
|
||||
* button while a filter is active. Only the bar collapses, never the card.
|
||||
* The collapsed state is remembered per `storageKey` (default: expanded).
|
||||
*/
|
||||
export function CollapsibleFilterPanel({
|
||||
storageKey,
|
||||
isFilterActive,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
onReset,
|
||||
children,
|
||||
}: {
|
||||
storageKey: string;
|
||||
isFilterActive: boolean;
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
onReset: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const barId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(readCollapsed(storageKey));
|
||||
}, [storageKey]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !isCollapsed;
|
||||
setIsCollapsed(next);
|
||||
writeCollapsed(storageKey, next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<IconButton
|
||||
ariaControls={barId}
|
||||
ariaExpanded={!isCollapsed}
|
||||
className="filter-panel-toggle"
|
||||
icon={isCollapsed ? ChevronRight : ChevronDown}
|
||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||
onClick={toggleCollapsed}
|
||||
/>
|
||||
<span className="filter-panel-label">Filter</span>
|
||||
{isCollapsed && isFilterActive ? (
|
||||
<span className="filter-panel-badge">Filter aktiv</span>
|
||||
) : null}
|
||||
<span aria-live="polite" className="filter-panel-count">
|
||||
{visibleCount} von {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? null : (
|
||||
<div className="filter-bar" id={barId}>
|
||||
{children}
|
||||
{isFilterActive ? (
|
||||
<IconButton
|
||||
className="filter-bar-reset"
|
||||
icon={FilterX}
|
||||
label="Zurücksetzen"
|
||||
onClick={onReset}
|
||||
showLabel
|
||||
title="Alle Filter zurücksetzen"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Search field for a filter bar. */
|
||||
export function FilterSearchField({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="filter-bar-field">
|
||||
<span className="filter-bar-label">{label}</span>
|
||||
<input
|
||||
className="filter-bar-search"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="search"
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
||||
|
||||
interface GzfPickerModalProps {
|
||||
|
|
@ -13,50 +12,14 @@ function formatFactor(value: number) {
|
|||
}
|
||||
|
||||
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the inline editor's blur callback before taking keyboard focus.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
} else if (event.key === "Tab") {
|
||||
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
|
||||
const first = buttons?.[0];
|
||||
const last = buttons?.[buttons.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||
<p className="text-secondary small mb-0">
|
||||
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||
* name; it is also the tooltip unless a more specific `title` is given.
|
||||
* With `showLabel` the label (or `children`) is rendered next to the icon.
|
||||
*/
|
||||
export function IconButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
children,
|
||||
className,
|
||||
tabIndex,
|
||||
ariaExpanded,
|
||||
ariaControls,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "default" | "danger" | "primary";
|
||||
showLabel?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
ariaExpanded?: boolean;
|
||||
ariaControls?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-controls={ariaControls}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-label={showLabel ? undefined : label}
|
||||
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tabIndex={tabIndex}
|
||||
title={title ?? label}
|
||||
type="button"
|
||||
>
|
||||
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||
{showLabel ? <span>{children ?? label}</span> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin vertical divider between groups of icon buttons. */
|
||||
export function IconButtonSeparator() {
|
||||
return <span aria-hidden="true" className="icon-button-separator" />;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
||||
* look. `label` is always the accessible name and, unless `title` is given,
|
||||
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
||||
*/
|
||||
export function IconLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
title,
|
||||
href,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
title?: string;
|
||||
href: string;
|
||||
variant?: "default" | "danger" | "primary";
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
aria-label={showLabel ? undefined : label}
|
||||
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||
href={href}
|
||||
title={title ?? label}
|
||||
>
|
||||
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||
{showLabel ? <span>{label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
toggleFilterValue,
|
||||
type FilterOption,
|
||||
} from "../utils/overview-filters";
|
||||
|
||||
/**
|
||||
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
||||
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
||||
* click and Escape.
|
||||
*/
|
||||
export function MultiSelectDropdown({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyLabel = "Keine Einträge vorhanden",
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly FilterOption[];
|
||||
selected: readonly string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const panelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const summary =
|
||||
selected.length === 0
|
||||
? "Alle"
|
||||
: selected.length === 1
|
||||
? options.find((option) => option.id === selected[0])?.label ??
|
||||
"1 ausgewählt"
|
||||
: `${selected.length} ausgewählt`;
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={containerRef}>
|
||||
<span className="filter-bar-label" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
ref={toggleRef}
|
||||
title={summary}
|
||||
type="button"
|
||||
>
|
||||
<span className="filter-dropdown-value" id={valueId}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
aria-labelledby={labelId}
|
||||
className="filter-dropdown-panel"
|
||||
id={panelId}
|
||||
role="group"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="filter-dropdown-option filter-dropdown-option-all">
|
||||
<input
|
||||
checked={selected.length === 0}
|
||||
onChange={() => onChange([])}
|
||||
type="checkbox"
|
||||
/>
|
||||
Alle
|
||||
</label>
|
||||
{options.map((option) => (
|
||||
<label className="filter-dropdown-option" key={option.id}>
|
||||
<input
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={() =>
|
||||
onChange(toggleFilterValue(selected, option.id))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,14 +28,6 @@ import {
|
|||
getProjectSnapshotRevisionDescription,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||
|
||||
interface ProjectVersionHistoryProps {
|
||||
projectId: string;
|
||||
|
|
@ -242,50 +234,50 @@ export function ProjectVersionHistory({
|
|||
<span className="badge text-bg-secondary">
|
||||
Aktuelle Revision {currentRevision}
|
||||
</span>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
icon={Undo2}
|
||||
label="Rückgängig"
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
icon={Redo2}
|
||||
label="Wiederholen"
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
ariaExpanded={isOpen}
|
||||
icon={isOpen ? ChevronUp : ChevronDown}
|
||||
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Wiederholen
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
@ -389,15 +381,14 @@ export function ProjectVersionHistory({
|
|||
</td>
|
||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||
<td className="text-end">
|
||||
<div className="overview-actions justify-content-end">
|
||||
<IconButton
|
||||
disabled={isBusy}
|
||||
icon={RotateCcw}
|
||||
label="Wiederherstellen"
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
disabled={isBusy}
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
type="button"
|
||||
>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Creates a random RFC 4122 version 4 UUID for new entities built in the
|
||||
* browser.
|
||||
*
|
||||
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
|
||||
* The app is also opened over plain HTTP from other machines in the LAN, where
|
||||
* it is undefined, so fall back to crypto.getRandomValues(), which is
|
||||
* available in every context.
|
||||
*/
|
||||
export function createClientId(
|
||||
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
|
||||
): string {
|
||||
if (typeof source.randomUUID === "function") {
|
||||
return source.randomUUID();
|
||||
}
|
||||
const bytes = source.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join("-");
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import type {
|
||||
DistributionBoardDto,
|
||||
FloorDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Pure filter logic for the project overview cards (Verteilungen,
|
||||
* Projektgeräte). Every multi-select filter uses the PM semantics: an empty
|
||||
* selection means "Alle", otherwise a record must match one selected value.
|
||||
* Records without a value are addressed through the dedicated "none" ids.
|
||||
*/
|
||||
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Option id for boards without floor ("Ohne Etage"). */
|
||||
export const withoutFloorFilterId = "__without_floor__";
|
||||
/** Option id for boards without supply type ("Nicht festgelegt"). */
|
||||
export const withoutSupplyTypeFilterId = "__without_supply_type__";
|
||||
/** Option id for project devices without cost group ("Ohne Kostengruppe"). */
|
||||
export const withoutCostGroupFilterId = "__without_cost_group__";
|
||||
|
||||
export interface DistributionBoardFilters {
|
||||
query: string;
|
||||
floorIds: string[];
|
||||
supplyTypes: string[];
|
||||
}
|
||||
|
||||
export interface ProjectDeviceFilters {
|
||||
query: string;
|
||||
categories: string[];
|
||||
phaseTypes: string[];
|
||||
costGroups: string[];
|
||||
}
|
||||
|
||||
export const emptyDistributionBoardFilters: DistributionBoardFilters = {
|
||||
query: "",
|
||||
floorIds: [],
|
||||
supplyTypes: [],
|
||||
};
|
||||
|
||||
export const emptyProjectDeviceFilters: ProjectDeviceFilters = {
|
||||
query: "",
|
||||
categories: [],
|
||||
phaseTypes: [],
|
||||
costGroups: [],
|
||||
};
|
||||
|
||||
export function normalizeFilterQuery(query: string) {
|
||||
return query.trim().toLocaleLowerCase("de");
|
||||
}
|
||||
|
||||
function matchesQuery(
|
||||
normalizedQuery: string,
|
||||
values: readonly (string | null | undefined)[]
|
||||
) {
|
||||
return (
|
||||
!normalizedQuery ||
|
||||
values.some((value) =>
|
||||
value?.toLocaleLowerCase("de").includes(normalizedQuery)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesSelection(selected: readonly string[], value: string) {
|
||||
return selected.length === 0 || selected.includes(value);
|
||||
}
|
||||
|
||||
/** Adds or removes one value from a multi-select selection. */
|
||||
export function toggleFilterValue(selected: readonly string[], value: string) {
|
||||
return selected.includes(value)
|
||||
? selected.filter((entry) => entry !== value)
|
||||
: [...selected, value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops selected values that are no longer offered (e.g. a deleted floor), so
|
||||
* no invisible selection keeps hiding records.
|
||||
*/
|
||||
export function retainAvailableFilterValues(
|
||||
selected: readonly string[],
|
||||
options: readonly FilterOption[]
|
||||
) {
|
||||
const available = new Set(options.map((option) => option.id));
|
||||
return selected.filter((value) => available.has(value));
|
||||
}
|
||||
|
||||
export function isDistributionBoardFilterActive(
|
||||
filters: DistributionBoardFilters
|
||||
) {
|
||||
return (
|
||||
normalizeFilterQuery(filters.query) !== "" ||
|
||||
filters.floorIds.length > 0 ||
|
||||
filters.supplyTypes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isProjectDeviceFilterActive(filters: ProjectDeviceFilters) {
|
||||
return (
|
||||
normalizeFilterQuery(filters.query) !== "" ||
|
||||
filters.categories.length > 0 ||
|
||||
filters.phaseTypes.length > 0 ||
|
||||
filters.costGroups.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Floors in their configured order, followed by "Ohne Etage". */
|
||||
export function buildFloorFilterOptions(
|
||||
floors: readonly FloorDto[]
|
||||
): FilterOption[] {
|
||||
return [
|
||||
...[...floors]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.name.localeCompare(right.name, "de", { numeric: true })
|
||||
)
|
||||
.map((floor) => ({ id: floor.id, label: floor.name })),
|
||||
{ id: withoutFloorFilterId, label: "Ohne Etage" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supply types in catalog order: every enabled or used type, plus
|
||||
* "Nicht festgelegt" when a board has no supply type.
|
||||
*/
|
||||
export function buildSupplyTypeFilterOptions(
|
||||
catalog: readonly string[],
|
||||
enabledSupplyTypes: readonly string[],
|
||||
boards: readonly Pick<DistributionBoardDto, "supplyType">[],
|
||||
labels: Readonly<Record<string, string>>
|
||||
): FilterOption[] {
|
||||
const offered = new Set<string>(enabledSupplyTypes);
|
||||
let hasBoardWithoutSupplyType = false;
|
||||
for (const board of boards) {
|
||||
if (board.supplyType) {
|
||||
offered.add(board.supplyType);
|
||||
} else {
|
||||
hasBoardWithoutSupplyType = true;
|
||||
}
|
||||
}
|
||||
const options: FilterOption[] = catalog
|
||||
.filter((supplyType) => offered.has(supplyType))
|
||||
.map((supplyType) => ({
|
||||
id: supplyType,
|
||||
label: labels[supplyType] ?? supplyType,
|
||||
}));
|
||||
if (hasBoardWithoutSupplyType) {
|
||||
options.push({ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Board number and location come with the distribution-board info card
|
||||
* (feature/print-view). They are optional here so the filter works with and
|
||||
* without those fields and searches them as soon as the DTO carries them.
|
||||
*/
|
||||
export function filterDistributionBoards<
|
||||
Board extends Pick<DistributionBoardDto, "name" | "floorId" | "supplyType"> & {
|
||||
boardNumber?: string | null;
|
||||
location?: string | null;
|
||||
},
|
||||
>(
|
||||
boards: readonly Board[],
|
||||
filters: DistributionBoardFilters,
|
||||
floorNameById: ReadonlyMap<string, string>
|
||||
): Board[] {
|
||||
const query = normalizeFilterQuery(filters.query);
|
||||
return boards.filter(
|
||||
(board) =>
|
||||
matchesQuery(query, [
|
||||
board.name,
|
||||
board.boardNumber,
|
||||
board.location,
|
||||
board.floorId ? floorNameById.get(board.floorId) : undefined,
|
||||
]) &&
|
||||
matchesSelection(
|
||||
filters.floorIds,
|
||||
board.floorId ?? withoutFloorFilterId
|
||||
) &&
|
||||
matchesSelection(
|
||||
filters.supplyTypes,
|
||||
board.supplyType ?? withoutSupplyTypeFilterId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getCostGroupFilterValue(costGroup: string | null) {
|
||||
const trimmed = costGroup?.trim() ?? "";
|
||||
return trimmed || withoutCostGroupFilterId;
|
||||
}
|
||||
|
||||
/** Distinct cost groups in natural order, followed by "Ohne Kostengruppe". */
|
||||
export function buildCostGroupFilterOptions(
|
||||
devices: readonly Pick<ProjectDeviceDto, "costGroup">[]
|
||||
): FilterOption[] {
|
||||
const costGroups = new Set<string>();
|
||||
for (const device of devices) {
|
||||
const value = getCostGroupFilterValue(device.costGroup);
|
||||
if (value !== withoutCostGroupFilterId) {
|
||||
costGroups.add(value);
|
||||
}
|
||||
}
|
||||
return [
|
||||
...[...costGroups]
|
||||
.sort((left, right) => left.localeCompare(right, "de", { numeric: true }))
|
||||
.map((costGroup) => ({ id: costGroup, label: costGroup })),
|
||||
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
|
||||
];
|
||||
}
|
||||
|
||||
export function filterProjectDevices<
|
||||
Device extends Pick<
|
||||
ProjectDeviceDto,
|
||||
| "name"
|
||||
| "displayName"
|
||||
| "category"
|
||||
| "phaseType"
|
||||
| "connectionKind"
|
||||
| "costGroup"
|
||||
>,
|
||||
>(
|
||||
devices: readonly Device[],
|
||||
filters: ProjectDeviceFilters,
|
||||
categoryLabels: Readonly<Record<string, string>> = {}
|
||||
): Device[] {
|
||||
const query = normalizeFilterQuery(filters.query);
|
||||
return devices.filter(
|
||||
(device) =>
|
||||
matchesQuery(query, [
|
||||
device.name,
|
||||
device.displayName,
|
||||
device.category,
|
||||
categoryLabels[device.category],
|
||||
device.connectionKind,
|
||||
device.costGroup,
|
||||
]) &&
|
||||
matchesSelection(filters.categories, device.category) &&
|
||||
matchesSelection(filters.phaseTypes, device.phaseType) &&
|
||||
matchesSelection(
|
||||
filters.costGroups,
|
||||
getCostGroupFilterValue(device.costGroup)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { describe, it } from "node:test";
|
||||
import { z } from "zod";
|
||||
import { createClientId } from "../src/frontend/utils/client-id.js";
|
||||
|
||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("client id", () => {
|
||||
it("uses crypto.randomUUID when the context provides it", () => {
|
||||
const id = createClientId({
|
||||
getRandomValues: () => {
|
||||
throw new Error("fallback must not be used");
|
||||
},
|
||||
randomUUID: () => "11111111-2222-4333-8444-555555555555",
|
||||
});
|
||||
assert.equal(id, "11111111-2222-4333-8444-555555555555");
|
||||
});
|
||||
|
||||
it("falls back to getRandomValues outside a secure context", () => {
|
||||
const insecure = {
|
||||
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
|
||||
} as Pick<Crypto, "getRandomValues">;
|
||||
const ids = new Set<string>();
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const id = createClientId(insecure);
|
||||
assert.match(id, uuidV4);
|
||||
assert.equal(z.string().uuid().safeParse(id).success, true);
|
||||
ids.add(id);
|
||||
}
|
||||
assert.equal(ids.size, 200);
|
||||
});
|
||||
|
||||
it("sets version and variant bits regardless of the random bytes", () => {
|
||||
const allOnes = {
|
||||
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
|
||||
if (array instanceof Uint8Array) {
|
||||
array.fill(0xff);
|
||||
}
|
||||
return array;
|
||||
},
|
||||
} as Pick<Crypto, "getRandomValues">;
|
||||
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildCostGroupFilterOptions,
|
||||
buildFloorFilterOptions,
|
||||
buildSupplyTypeFilterOptions,
|
||||
emptyDistributionBoardFilters,
|
||||
emptyProjectDeviceFilters,
|
||||
filterDistributionBoards,
|
||||
filterProjectDevices,
|
||||
isDistributionBoardFilterActive,
|
||||
isProjectDeviceFilterActive,
|
||||
retainAvailableFilterValues,
|
||||
toggleFilterValue,
|
||||
withoutCostGroupFilterId,
|
||||
withoutFloorFilterId,
|
||||
withoutSupplyTypeFilterId,
|
||||
} from "../src/frontend/utils/overview-filters.js";
|
||||
import type {
|
||||
DistributionBoardDto,
|
||||
FloorDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
type TestBoard = Pick<
|
||||
DistributionBoardDto,
|
||||
"id" | "name" | "floorId" | "supplyType"
|
||||
>;
|
||||
|
||||
function board(
|
||||
id: string,
|
||||
name: string,
|
||||
floorId: string | null,
|
||||
supplyType: DistributionBoardDto["supplyType"]
|
||||
): TestBoard {
|
||||
return { id, name, floorId, supplyType };
|
||||
}
|
||||
|
||||
function device(
|
||||
id: string,
|
||||
overrides: Partial<ProjectDeviceDto>
|
||||
): ProjectDeviceDto {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
name: `device-${id}`,
|
||||
displayName: `Gerät ${id}`,
|
||||
phaseType: "single_phase",
|
||||
connectionKind: null,
|
||||
costGroup: null,
|
||||
category: "single_phase",
|
||||
quantity: 1,
|
||||
powerPerUnit: 1,
|
||||
simultaneityFactor: 1,
|
||||
totalPower: 1,
|
||||
cosPhi: null,
|
||||
remark: null,
|
||||
voltageV: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const floors: FloorDto[] = [
|
||||
{ id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 },
|
||||
{ id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 },
|
||||
];
|
||||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||||
|
||||
const boards = [
|
||||
board("b1", "UV-EG-01", "floor-eg", "AV"),
|
||||
board("b2", "UV-OG-01", "floor-og", "SV"),
|
||||
board("b3", "HV", null, "AV"),
|
||||
board("b4", "SiBe-Zentrale", null, null),
|
||||
];
|
||||
|
||||
describe("overview filters: distribution boards", () => {
|
||||
it("returns every board without an active filter", () => {
|
||||
assert.equal(isDistributionBoardFilterActive(emptyDistributionBoardFilters), false);
|
||||
assert.deepEqual(
|
||||
filterDistributionBoards(boards, emptyDistributionBoardFilters, floorNameById),
|
||||
boards
|
||||
);
|
||||
});
|
||||
|
||||
it("also searches board number and location when the boards carry them", () => {
|
||||
const withInfo = [
|
||||
{ ...boards[0]!, boardNumber: "UV 0.1", location: "Raum 003 Technik" },
|
||||
{ ...boards[1]!, boardNumber: null, location: null },
|
||||
];
|
||||
assert.deepEqual(
|
||||
filterDistributionBoards(
|
||||
withInfo,
|
||||
{ ...emptyDistributionBoardFilters, query: "uv 0.1" },
|
||||
floorNameById
|
||||
).map((entry) => entry.id),
|
||||
[withInfo[0]!.id]
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterDistributionBoards(
|
||||
withInfo,
|
||||
{ ...emptyDistributionBoardFilters, query: "technik" },
|
||||
floorNameById
|
||||
).map((entry) => entry.id),
|
||||
[withInfo[0]!.id]
|
||||
);
|
||||
});
|
||||
|
||||
it("searches board and floor names case-insensitively and ignores blank queries", () => {
|
||||
const byFloorName = filterDistributionBoards(
|
||||
boards,
|
||||
{ ...emptyDistributionBoardFilters, query: " og " },
|
||||
floorNameById
|
||||
);
|
||||
assert.deepEqual(byFloorName.map((entry) => entry.id), ["b2"]);
|
||||
const byBoardName = filterDistributionBoards(
|
||||
boards,
|
||||
{ ...emptyDistributionBoardFilters, query: "sibe" },
|
||||
floorNameById
|
||||
);
|
||||
assert.deepEqual(byBoardName.map((entry) => entry.id), ["b4"]);
|
||||
assert.equal(
|
||||
isDistributionBoardFilterActive({ ...emptyDistributionBoardFilters, query: " " }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("filters floors with the dedicated option for boards without floor", () => {
|
||||
const result = filterDistributionBoards(
|
||||
boards,
|
||||
{ ...emptyDistributionBoardFilters, floorIds: ["floor-eg", withoutFloorFilterId] },
|
||||
floorNameById
|
||||
);
|
||||
assert.deepEqual(result.map((entry) => entry.id), ["b1", "b3", "b4"]);
|
||||
});
|
||||
|
||||
it("combines supply type with the other criteria", () => {
|
||||
const filters = {
|
||||
query: "",
|
||||
floorIds: [withoutFloorFilterId],
|
||||
supplyTypes: ["AV"],
|
||||
};
|
||||
assert.equal(isDistributionBoardFilterActive(filters), true);
|
||||
assert.deepEqual(
|
||||
filterDistributionBoards(boards, filters, floorNameById).map((entry) => entry.id),
|
||||
["b3"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterDistributionBoards(
|
||||
boards,
|
||||
{ ...emptyDistributionBoardFilters, supplyTypes: [withoutSupplyTypeFilterId] },
|
||||
floorNameById
|
||||
).map((entry) => entry.id),
|
||||
["b4"]
|
||||
);
|
||||
});
|
||||
|
||||
it("offers floors in configured order followed by 'Ohne Etage'", () => {
|
||||
assert.deepEqual(buildFloorFilterOptions(floors), [
|
||||
{ id: "floor-eg", label: "EG" },
|
||||
{ id: "floor-og", label: "1. OG" },
|
||||
{ id: withoutFloorFilterId, label: "Ohne Etage" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers enabled and used supply types in catalog order", () => {
|
||||
const options = buildSupplyTypeFilterOptions(
|
||||
["AV", "SV", "EV", "USV"],
|
||||
["USV", "AV"],
|
||||
boards,
|
||||
{ AV: "AV – Allgemein", SV: "SV – Sicherheit" }
|
||||
);
|
||||
assert.deepEqual(options, [
|
||||
{ id: "AV", label: "AV – Allgemein" },
|
||||
{ id: "SV", label: "SV – Sicherheit" },
|
||||
{ id: "USV", label: "USV" },
|
||||
{ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overview filters: project devices", () => {
|
||||
const devices = [
|
||||
device("d1", {
|
||||
displayName: "Steckdose Büro",
|
||||
costGroup: "444",
|
||||
connectionKind: "Steckdose",
|
||||
}),
|
||||
device("d2", {
|
||||
displayName: "Durchlauferhitzer",
|
||||
phaseType: "three_phase",
|
||||
category: "three_phase",
|
||||
costGroup: " 445 ",
|
||||
}),
|
||||
device("d3", {
|
||||
displayName: "Downlight",
|
||||
category: "lighting",
|
||||
costGroup: " ",
|
||||
}),
|
||||
device("d4", { displayName: "Kaffeemaschine", costGroup: null }),
|
||||
];
|
||||
|
||||
it("searches names, connection kind, cost group and the German category label", () => {
|
||||
assert.deepEqual(
|
||||
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "STECK" }).map(
|
||||
(entry) => entry.id
|
||||
),
|
||||
["d1"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterProjectDevices(
|
||||
devices,
|
||||
{ ...emptyProjectDeviceFilters, query: "beleucht" },
|
||||
{ lighting: "Beleuchtung" }
|
||||
).map((entry) => entry.id),
|
||||
["d3"]
|
||||
);
|
||||
});
|
||||
|
||||
it("filters category, phase type and cost group together", () => {
|
||||
const filters = {
|
||||
query: "",
|
||||
categories: ["single_phase", "three_phase"],
|
||||
phaseTypes: ["three_phase"],
|
||||
costGroups: ["445"],
|
||||
};
|
||||
assert.equal(isProjectDeviceFilterActive(filters), true);
|
||||
assert.deepEqual(
|
||||
filterProjectDevices(devices, filters).map((entry) => entry.id),
|
||||
["d2"]
|
||||
);
|
||||
});
|
||||
|
||||
it("treats blank cost groups as 'Ohne Kostengruppe'", () => {
|
||||
assert.deepEqual(
|
||||
filterProjectDevices(devices, {
|
||||
...emptyProjectDeviceFilters,
|
||||
costGroups: [withoutCostGroupFilterId],
|
||||
}).map((entry) => entry.id),
|
||||
["d3", "d4"]
|
||||
);
|
||||
assert.deepEqual(buildCostGroupFilterOptions(devices), [
|
||||
{ id: "444", label: "444" },
|
||||
{ id: "445", label: "445" },
|
||||
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list when the filters hide every device", () => {
|
||||
assert.deepEqual(
|
||||
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "gibt es nicht" }),
|
||||
[]
|
||||
);
|
||||
assert.equal(isProjectDeviceFilterActive(emptyProjectDeviceFilters), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overview filters: selection helpers", () => {
|
||||
it("toggles values without mutating the selection", () => {
|
||||
const selected = ["a"];
|
||||
assert.deepEqual(toggleFilterValue(selected, "b"), ["a", "b"]);
|
||||
assert.deepEqual(toggleFilterValue(selected, "a"), []);
|
||||
assert.deepEqual(selected, ["a"]);
|
||||
});
|
||||
|
||||
it("drops selected values that are no longer offered", () => {
|
||||
assert.deepEqual(
|
||||
retainAvailableFilterValues(["floor-eg", "deleted-floor"], buildFloorFilterOptions(floors)),
|
||||
["floor-eg"]
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue