Integrate compact editor actions and fix header units
This commit is contained in:
commit
203757b92c
5 changed files with 467 additions and 217 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -167,6 +167,17 @@ Keyboard behavior:
|
||||||
|
|
||||||
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
||||||
|
|
||||||
|
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
|
||||||
|
`lucide-react` icons). The German action text is always the accessible name and
|
||||||
|
tooltip; the toolbar shows icon plus text, group headers and rows show icons
|
||||||
|
only, separated into logical blocks with `IconButtonSeparator`. Destructive
|
||||||
|
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
|
||||||
|
removing a device row).
|
||||||
|
Column headers show the label (click to sort) with a funnel icon for the
|
||||||
|
value filter; an active filter is highlighted and shows its value count.
|
||||||
|
Header labels break only between words and put a trailing unit such as
|
||||||
|
`[kW]` on a second line.
|
||||||
|
|
||||||
## Bootstrap / Styling Rule
|
## Bootstrap / Styling Rule
|
||||||
|
|
||||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||||
|
|
||||||
## Filtering and Sorting
|
## Filtering and Sorting
|
||||||
|
|
||||||
|
- Clicking a column label changes sorting; the funnel icon next to it opens
|
||||||
|
the value filter. An active filter is highlighted and shows the number of
|
||||||
|
selected values. Both controls retain German accessible names and tooltips.
|
||||||
|
- Header labels wrap between words; trailing units such as `[kW]` appear on
|
||||||
|
a separate line.
|
||||||
- Per-column filtering works on normalized displayed values.
|
- Per-column filtering works on normalized displayed values.
|
||||||
- Sorting is view-level first and treats circuits as blocks.
|
- Sorting is view-level first and treats circuits as blocks.
|
||||||
- Multi-device circuits are not split during sort.
|
- Multi-device circuits are not split during sort.
|
||||||
- Sorting alone does not persist order.
|
- Sorting alone does not persist order.
|
||||||
|
|
||||||
|
## Action Buttons
|
||||||
|
|
||||||
|
The editor toolbar uses icons with visible German labels. Group headers and
|
||||||
|
circuit/device rows use compact icon buttons with German accessible names and
|
||||||
|
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
|
||||||
|
their roles. Dividers separate related actions; destructive actions use the
|
||||||
|
danger style. Circuit deletion uses a trash icon, while device-row deletion
|
||||||
|
uses a crossed-out list icon.
|
||||||
|
|
||||||
|
The shared `src/frontend/components/icon-button.tsx` component preserves
|
||||||
|
disabled states and expanded states for toggles. Row and group action buttons
|
||||||
|
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
|
||||||
|
unchanged. The existing command handlers and deletion confirmations are reused.
|
||||||
|
|
||||||
## Apply Sorted Order
|
## Apply Sorted Order
|
||||||
|
|
||||||
`Apply sorted order` persists current sorted block order to backend by section.
|
`Apply sorted order` persists current sorted block order to backend by section.
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,54 @@ a.kpi:hover {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon buttons (circuit list editor toolbar, group and row actions) */
|
||||||
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:not(:disabled):hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-danger:not(:disabled):hover {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover {
|
||||||
|
color: #fff;
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-separator {
|
||||||
|
align-self: stretch;
|
||||||
|
width: 1px;
|
||||||
|
margin: 0.1rem 0.15rem;
|
||||||
|
background: var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-toolbar .project-device-drawer-toggle {
|
.editor-toolbar .project-device-drawer-toggle {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
background: var(--accent-blue);
|
background: var(--accent-blue);
|
||||||
|
|
@ -844,24 +892,37 @@ a.kpi:hover {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One header row: label (sort) on the left, filter icon on the right. */
|
||||||
.tree-grid .header-cell {
|
.tree-grid .header-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-btn {
|
.tree-grid .header-filter-btn {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
width: 100%;
|
flex: none;
|
||||||
border: 1px solid var(--panel-border-strong);
|
align-items: center;
|
||||||
background: var(--panel-bg);
|
gap: 0.15rem;
|
||||||
color: var(--text-strong);
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.15rem 0.35rem;
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem 0.2rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .header-filter-btn:hover,
|
||||||
|
.tree-grid .header-filter-btn:focus-visible {
|
||||||
|
border-color: var(--panel-border-strong);
|
||||||
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-sort-btn {
|
.tree-grid .header-sort-btn {
|
||||||
|
|
@ -879,7 +940,10 @@ a.kpi:hover {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
overflow-wrap: anywhere;
|
/* Break only between words; never inside a word such as "Bemerkung". */
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
hyphens: manual;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -919,6 +983,18 @@ a.kpi:hover {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .header-cell .header-sort-btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .header-unit {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .header-sort-btn:hover span:first-child,
|
.tree-grid .header-sort-btn:hover span:first-child,
|
||||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
@ -935,6 +1011,11 @@ a.kpi:hover {
|
||||||
background: var(--accent-blue-soft-bg);
|
background: var(--accent-blue-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .header-filter-count {
|
||||||
|
min-width: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-menu {
|
.tree-grid .header-filter-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 7;
|
z-index: 7;
|
||||||
|
|
@ -1153,7 +1234,20 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .section-actions {
|
.tree-grid .section-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-actions .icon-button,
|
||||||
|
.tree-grid .action-cell .icon-button {
|
||||||
|
min-width: 1.75rem;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-actions .icon-button:disabled,
|
||||||
|
.tree-grid .action-cell .icon-button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .section-actions button {
|
.tree-grid .section-actions button {
|
||||||
|
|
@ -1265,7 +1359,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .action-cell {
|
.tree-grid .action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .action-cell button {
|
.tree-grid .action-cell button {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowDown01,
|
||||||
|
ArrowUp,
|
||||||
|
Check,
|
||||||
|
Columns3,
|
||||||
|
FilterX,
|
||||||
|
FolderPlus,
|
||||||
|
Funnel,
|
||||||
|
ListOrdered,
|
||||||
|
ListPlus,
|
||||||
|
ListX,
|
||||||
|
PackagePlus,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
ShieldPlus,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||||
import {
|
import {
|
||||||
inferProjectDeviceSectionKey,
|
inferProjectDeviceSectionKey,
|
||||||
isProjectDevicePlacementValid,
|
isProjectDevicePlacementValid,
|
||||||
|
|
@ -247,6 +270,24 @@ function createValuesFromEditPatch(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header labels break only between words: hyphens become non-breaking and a
|
||||||
|
* trailing unit such as "[kW]" is always shown on its own second line, so
|
||||||
|
* header heights stay uniform.
|
||||||
|
*/
|
||||||
|
function renderColumnHeaderLabel(label: string) {
|
||||||
|
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
|
||||||
|
const name = (match ? match[1] : label).replace(/-/g, "‑");
|
||||||
|
return match ? (
|
||||||
|
<>
|
||||||
|
{name}
|
||||||
|
<span className="header-unit">{match[2]}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
||||||
/*
|
/*
|
||||||
Circuit-tree editor invariant overview:
|
Circuit-tree editor invariant overview:
|
||||||
|
|
@ -2996,30 +3037,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Undo2}
|
||||||
Rückgängig
|
label="Rückgängig"
|
||||||
</button>
|
onClick={() => void handleUndo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleRedo()}
|
<IconButton
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Redo2}
|
||||||
Wiederholen
|
label="Wiederholen"
|
||||||
</button>
|
onClick={() => void handleRedo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={clearSortAndFilters}
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
ariaExpanded={isColumnMenuOpen}
|
||||||
|
icon={Columns3}
|
||||||
|
label="Spalten"
|
||||||
|
onClick={toggleColumnSettingsMenu}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||||
>
|
icon={FilterX}
|
||||||
Sortierung/Filter zurücksetzen
|
label="Sortierung/Filter zurücksetzen"
|
||||||
</button>
|
onClick={clearSortAndFilters}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
showLabel
|
||||||
Spalten
|
/>
|
||||||
</button>
|
|
||||||
{renderColumnSettingsMenu("col-empty")}
|
{renderColumnSettingsMenu("col-empty")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3141,82 +3187,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Undo2}
|
||||||
|
label="Rückgängig"
|
||||||
|
onClick={() => void handleUndo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.undoDepth
|
historyState?.undoDepth
|
||||||
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Rückgängig
|
<IconButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRedo()}
|
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Redo2}
|
||||||
|
label="Wiederholen"
|
||||||
|
onClick={() => void handleRedo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.redoDepth
|
historyState?.redoDepth
|
||||||
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Wiederholen
|
<IconButtonSeparator />
|
||||||
</button>
|
|
||||||
{isSortedView ? (
|
{isSortedView ? (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleApplySortedOrder()}
|
|
||||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||||
|
icon={Check}
|
||||||
|
label="Sortierung übernehmen"
|
||||||
|
onClick={() => void handleApplySortedOrder()}
|
||||||
|
showLabel
|
||||||
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
||||||
>
|
/>
|
||||||
Sortierung übernehmen
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
<IconButton
|
||||||
Spalten
|
ariaExpanded={isColumnMenuOpen}
|
||||||
</button>
|
icon={Columns3}
|
||||||
<button
|
label="Spalten"
|
||||||
aria-controls="project-device-drawer"
|
onClick={toggleColumnSettingsMenu}
|
||||||
aria-expanded={isProjectDeviceDrawerOpen}
|
showLabel
|
||||||
className="project-device-drawer-toggle"
|
/>
|
||||||
type="button"
|
<IconButton
|
||||||
onClick={() =>
|
disabled={!isSortedView && !hasActiveFilters}
|
||||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
icon={FilterX}
|
||||||
}
|
label="Sortierung/Filter zurücksetzen"
|
||||||
>
|
onClick={clearSortAndFilters}
|
||||||
{isProjectDeviceDrawerOpen
|
showLabel
|
||||||
? "Projektgeräte schließen"
|
/>
|
||||||
: `Projektgeräte öffnen (${projectDevices.length})`}
|
<IconButtonSeparator />
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || historyBusy}
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={FolderPlus}
|
||||||
|
label="Stromkreisgruppe hinzufügen"
|
||||||
|
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={PackagePlus}
|
||||||
|
label="Verteilergerät hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
role: "auxiliary",
|
role: "auxiliary",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
showLabel
|
||||||
Verteilergerät hinzufügen
|
/>
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
ariaControls="project-device-drawer"
|
||||||
type="button"
|
ariaExpanded={isProjectDeviceDrawerOpen}
|
||||||
disabled={isSaving || historyBusy}
|
className="project-device-drawer-toggle"
|
||||||
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
|
||||||
>
|
label={
|
||||||
Stromkreisgruppe hinzufügen
|
isProjectDeviceDrawerOpen
|
||||||
</button>
|
? "Projektgeräte schließen"
|
||||||
<button
|
: `Projektgeräte öffnen (${projectDevices.length})`
|
||||||
type="button"
|
}
|
||||||
onClick={clearSortAndFilters}
|
onClick={() =>
|
||||||
disabled={!isSortedView && !hasActiveFilters}
|
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||||
>
|
}
|
||||||
Sortierung/Filter zurücksetzen
|
showLabel
|
||||||
</button>
|
/>
|
||||||
{renderColumnSettingsMenu("col")}
|
{renderColumnSettingsMenu("col")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3406,7 +3460,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{column.label}</span>
|
<span className="header-label">
|
||||||
|
{renderColumnHeaderLabel(column.label)}
|
||||||
|
</span>
|
||||||
{sortState?.key === column.key ? (
|
{sortState?.key === column.key ? (
|
||||||
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
|
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
|
||||||
{sortState.direction === "asc" ? "↑" : "↓"}
|
{sortState.direction === "asc" ? "↑" : "↓"}
|
||||||
|
|
@ -3417,6 +3473,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
type="button"
|
type="button"
|
||||||
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
||||||
onClick={() => toggleColumnFilterMenu(column.key)}
|
onClick={() => toggleColumnFilterMenu(column.key)}
|
||||||
|
aria-label={`${getFullColumnLabel(column)} filtern`}
|
||||||
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
|
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
|
||||||
title={
|
title={
|
||||||
(columnFilters[column.key]?.length ?? 0) > 0
|
(columnFilters[column.key]?.length ?? 0) > 0
|
||||||
|
|
@ -3424,7 +3481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
: `${getFullColumnLabel(column)} filtern`
|
: `${getFullColumnLabel(column)} filtern`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||||
|
{(columnFilters[column.key]?.length ?? 0) > 0 ? (
|
||||||
|
<span className="header-filter-count">{columnFilters[column.key]?.length}</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{openFilterColumn === column.key ? (
|
{openFilterColumn === column.key ? (
|
||||||
|
|
@ -3718,105 +3778,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<button
|
<IconButton
|
||||||
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}
|
disabled={isSaving}
|
||||||
title={
|
icon={Plus}
|
||||||
canDeleteCircuitGroup(section)
|
label="Stromkreis hinzufügen"
|
||||||
? "Leere Stromkreisgruppe entfernen"
|
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||||
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleDeleteCircuitGroup(section)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppe entfernen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasUpstreamProtection ||
|
hasUpstreamProtection ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Vorsicherung hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3829,17 +3805,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Vorsicherung hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<span className="icon-button-text">VS</span>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasGroupRcd ||
|
hasGroupRcd ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Gruppen-FI hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3852,26 +3829,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Gruppen-FI hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disabled={isSaving}
|
|
||||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
|
||||||
>
|
>
|
||||||
Stromkreis hinzufügen
|
<span className="icon-button-text">FI</span>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButtonSeparator />
|
||||||
type="button"
|
<IconButton
|
||||||
|
disabled={!section.category || !section.groupNumber}
|
||||||
|
icon={Pencil}
|
||||||
|
label="Gruppe bearbeiten"
|
||||||
|
onClick={() =>
|
||||||
|
setCircuitGroupEditorIntent({
|
||||||
|
kind: "edit",
|
||||||
|
section,
|
||||||
|
})
|
||||||
|
}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowUp}
|
||||||
|
label="Gruppe nach oben"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, -1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach oben (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowDown}
|
||||||
|
label="Gruppe nach unten"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, 1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach unten (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
disabled={hasActiveSortOrFilter || isSaving}
|
disabled={hasActiveSortOrFilter || isSaving}
|
||||||
|
icon={ArrowDown01}
|
||||||
|
label="Bereich neu nummerieren"
|
||||||
onClick={() => void handleRenumberSection(section.id)}
|
onClick={() => void handleRenumberSection(section.id)}
|
||||||
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
tabIndex={-1}
|
||||||
>
|
title={
|
||||||
Bereich neu nummerieren
|
hasActiveSortOrFilter
|
||||||
</button>
|
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
|
||||||
|
: "Stromkreise dieser Gruppe neu nummerieren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
hasActiveSortOrFilter ||
|
||||||
|
!section.category ||
|
||||||
|
!canRenumberCircuitGroups(
|
||||||
|
data.sections,
|
||||||
|
section.category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ListOrdered}
|
||||||
|
label="Gruppen-BMK neu nummerieren"
|
||||||
|
onClick={() =>
|
||||||
|
section.category
|
||||||
|
? void handleRenumberCircuitGroups(
|
||||||
|
section.category
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={
|
||||||
|
hasActiveSortOrFilter
|
||||||
|
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
||||||
|
: "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Gruppe entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDeleteCircuitGroup(section)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={
|
||||||
|
canDeleteCircuitGroup(section)
|
||||||
|
? "Leere Stromkreisgruppe entfernen"
|
||||||
|
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||||
|
|
@ -4468,28 +4531,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
>
|
>
|
||||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={ListPlus}
|
||||||
|
label="Manuelles Gerät hinzufügen"
|
||||||
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
||||||
>
|
|
||||||
Manuelles Gerät hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Stromkreis löschen"
|
||||||
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
||||||
>
|
tabIndex={-1}
|
||||||
Stromkreis löschen
|
variant="danger"
|
||||||
</button>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{row.device ? (
|
{row.device ? (
|
||||||
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
<IconButton
|
||||||
Gerät löschen
|
disabled={isSaving}
|
||||||
</button>
|
icon={ListX}
|
||||||
|
label="Gerät löschen"
|
||||||
|
onClick={() => void handleDeleteDevice(row.device!.id)}
|
||||||
|
tabIndex={-1}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">
|
<span className="drop-hint">
|
||||||
|
|
|
||||||
59
src/frontend/components/icon-button.tsx
Normal file
59
src/frontend/components/icon-button.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import 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" />;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue