forked from jappel/leistungsbilanz-ts
Add icon actions and collapsible filters to the project overview
The overview now uses the same icon buttons as the circuit list editor: row actions are icon-only IconButtons (Pencil, Link2, Library, Trash2), card-header and panel actions show icon plus text, and the circuit-list link uses the new IconLink. The version history uses Undo2/Redo2, a chevron toggle and a RotateCcw restore action. Page-header buttons stay Bootstrap buttons with a lucide icon so the upcoming "Seitenansicht" button of feature/print-view merges without conflicts and matches them. The Verteilungen and Projektgeräte cards get a PM-style filter bar: search plus MultiSelectDropdown (checkbox list with "Alle", closes on outside click and Escape) and "Zurücksetzen" while a filter is active. Only the bar collapses; the state is kept per card in localStorage and a collapsed active filter shows "Filter aktiv". The header row shows "x von y", and an empty-state row explains when filters hide everything. The filter logic is a pure module (overview-filters.ts) with node tests; selections that are no longer offered (deleted floor) are ignored. IconButton now imports React explicitly like the other components that are rendered in node tests with the classic JSX runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d95f0bf5c0
commit
837b29c7c5
11 changed files with 1404 additions and 156 deletions
|
|
@ -177,6 +177,14 @@ 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.
|
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
|
Header labels break only between words and put a trailing unit such as
|
||||||
`[kW]` on a second line.
|
`[kW]` on a second line.
|
||||||
|
The project overview uses the same rule: row actions are icon-only
|
||||||
|
`IconButton`s, card-header and panel actions show icon plus text, navigation
|
||||||
|
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
|
||||||
|
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
|
||||||
|
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
|
||||||
|
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
|
||||||
|
the bar collapses, its state is kept per card in `localStorage`, a collapsed
|
||||||
|
active filter shows "Filter aktiv".
|
||||||
|
|
||||||
## Bootstrap / Styling Rule
|
## Bootstrap / Styling Rule
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,12 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
|
||||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
|
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
|
||||||
|
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
|
||||||
|
Kategorie/Anschluss/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`.
|
||||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,263 @@ a {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ─────
|
||||||
|
Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen
|
||||||
|
kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der
|
||||||
|
PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an
|
||||||
|
die Tokens dieser App. */
|
||||||
|
|
||||||
|
.btn-with-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions .icon-button {
|
||||||
|
min-width: 1.9rem;
|
||||||
|
min-height: 1.85rem;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions .icon-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions .icon-button-primary {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions .icon-button-primary:not(:disabled):hover {
|
||||||
|
border-color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover {
|
||||||
|
border-color: var(--color-accent-pale);
|
||||||
|
background: var(--color-accent-pale);
|
||||||
|
color: var(--color-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-actions .icon-button-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
padding: 0.55rem 1rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-header .filter-panel-toggle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-header .filter-panel-toggle:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-label,
|
||||||
|
.filter-bar-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fbe9d9;
|
||||||
|
color: var(--color-attention-dark);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .filter-panel-badge {
|
||||||
|
background: #3a2e1c;
|
||||||
|
color: #f0b880;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-sm) var(--space-md);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar-field,
|
||||||
|
.filter-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar-search,
|
||||||
|
.filter-dropdown-toggle {
|
||||||
|
min-height: 2.1rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar-search {
|
||||||
|
width: 15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar-search:focus,
|
||||||
|
.filter-dropdown-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 11em;
|
||||||
|
max-width: 18em;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-toggle:hover,
|
||||||
|
.filter-dropdown-toggle.is-active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-toggle.is-active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active {
|
||||||
|
color: var(--color-accent-pale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-toggle svg {
|
||||||
|
flex: none;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: 0 6px 20px var(--shadow-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-option:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-option input {
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-option-all {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown-empty {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .filter-bar-reset {
|
||||||
|
min-height: 2.1rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.kpi-grid {
|
.kpi-grid {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,47 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
FileUp,
|
||||||
|
Library,
|
||||||
|
Link2,
|
||||||
|
ListTree,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
Unlink,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { IconButton } from "../../../frontend/components/icon-button";
|
||||||
|
import { IconLink } from "../../../frontend/components/icon-link";
|
||||||
|
import {
|
||||||
|
CollapsibleFilterPanel,
|
||||||
|
FilterSearchField,
|
||||||
|
} from "../../../frontend/components/filter-panel";
|
||||||
|
import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown";
|
||||||
|
import {
|
||||||
|
circuitGroupCategories,
|
||||||
|
circuitGroupCategoryLabels,
|
||||||
|
} from "../../../shared/constants/circuit-group";
|
||||||
|
import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board";
|
||||||
|
import {
|
||||||
|
buildCostGroupFilterOptions,
|
||||||
|
buildFloorFilterOptions,
|
||||||
|
buildSupplyTypeFilterOptions,
|
||||||
|
emptyDistributionBoardFilters,
|
||||||
|
emptyProjectDeviceFilters,
|
||||||
|
filterDistributionBoards,
|
||||||
|
filterProjectDevices,
|
||||||
|
isDistributionBoardFilterActive,
|
||||||
|
isProjectDeviceFilterActive,
|
||||||
|
retainAvailableFilterValues,
|
||||||
|
type DistributionBoardFilters,
|
||||||
|
type FilterOption,
|
||||||
|
type ProjectDeviceFilters,
|
||||||
|
} from "../../../frontend/utils/overview-filters";
|
||||||
import {
|
import {
|
||||||
copyGlobalDeviceToProject,
|
copyGlobalDeviceToProject,
|
||||||
copyDistributionBoard,
|
copyDistributionBoard,
|
||||||
|
|
@ -77,6 +118,29 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectDeviceCategoryFilterOptions: FilterOption[] =
|
||||||
|
circuitGroupCategories.map((category) => ({
|
||||||
|
id: category,
|
||||||
|
label: circuitGroupCategoryLabels[category],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projectDevicePhaseTypeLabels: Record<ProjectDeviceDto["phaseType"], string> = {
|
||||||
|
single_phase: "1-phasig",
|
||||||
|
three_phase: "3-phasig",
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectDevicePhaseTypeFilterOptions: FilterOption[] = (
|
||||||
|
["single_phase", "three_phase"] as const
|
||||||
|
).map((phaseType) => ({
|
||||||
|
id: phaseType,
|
||||||
|
label: projectDevicePhaseTypeLabels[phaseType],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const boardFilterCollapsedStorageKey =
|
||||||
|
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
|
||||||
|
const projectDeviceFilterCollapsedStorageKey =
|
||||||
|
"leistungsbilanz:overview-filter-collapsed:project-devices";
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const params = useParams<{ projectId: string }>();
|
const params = useParams<{ projectId: string }>();
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
|
|
@ -113,7 +177,11 @@ export default function ProjectDetailPage() {
|
||||||
useState(false);
|
useState(false);
|
||||||
const [editingProjectDevice, setEditingProjectDevice] =
|
const [editingProjectDevice, setEditingProjectDevice] =
|
||||||
useState<ProjectDeviceDto | null>(null);
|
useState<ProjectDeviceDto | null>(null);
|
||||||
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
|
||||||
|
emptyDistributionBoardFilters
|
||||||
|
);
|
||||||
|
const [projectDeviceFilters, setProjectDeviceFilters] =
|
||||||
|
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
|
||||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
|
|
@ -164,22 +232,67 @@ export default function ProjectDetailPage() {
|
||||||
const floorCount = useMemo(() => floors.length, [floors.length]);
|
const floorCount = useMemo(() => floors.length, [floors.length]);
|
||||||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||||||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||||||
const visibleProjectDevices = useMemo(() => {
|
const costGroupFilterOptions = useMemo(
|
||||||
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
() => buildCostGroupFilterOptions(projectDevices),
|
||||||
if (!query) {
|
[projectDevices]
|
||||||
return projectDevices;
|
);
|
||||||
}
|
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
|
||||||
return projectDevices.filter((device) =>
|
() => ({
|
||||||
[
|
...projectDeviceFilters,
|
||||||
device.name,
|
costGroups: retainAvailableFilterValues(
|
||||||
device.displayName,
|
projectDeviceFilters.costGroups,
|
||||||
device.category,
|
costGroupFilterOptions
|
||||||
device.connectionKind,
|
),
|
||||||
device.costGroup,
|
}),
|
||||||
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
[costGroupFilterOptions, projectDeviceFilters]
|
||||||
|
);
|
||||||
|
const visibleProjectDevices = useMemo(
|
||||||
|
() =>
|
||||||
|
filterProjectDevices(
|
||||||
|
projectDevices,
|
||||||
|
effectiveProjectDeviceFilters,
|
||||||
|
circuitGroupCategoryLabels
|
||||||
|
),
|
||||||
|
[effectiveProjectDeviceFilters, projectDevices]
|
||||||
);
|
);
|
||||||
}, [projectDeviceQuery, projectDevices]);
|
|
||||||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
||||||
|
const floorFilterOptions = useMemo(
|
||||||
|
() => buildFloorFilterOptions(floors),
|
||||||
|
[floors]
|
||||||
|
);
|
||||||
|
const supplyTypeFilterOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildSupplyTypeFilterOptions(
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
project?.enabledDistributionBoardSupplyTypes ?? [],
|
||||||
|
boards,
|
||||||
|
distributionBoardSupplyTypeLabels
|
||||||
|
),
|
||||||
|
[boards, project?.enabledDistributionBoardSupplyTypes]
|
||||||
|
);
|
||||||
|
const effectiveBoardFilters = useMemo<DistributionBoardFilters>(
|
||||||
|
() => ({
|
||||||
|
...boardFilters,
|
||||||
|
floorIds: retainAvailableFilterValues(
|
||||||
|
boardFilters.floorIds,
|
||||||
|
floorFilterOptions
|
||||||
|
),
|
||||||
|
supplyTypes: retainAvailableFilterValues(
|
||||||
|
boardFilters.supplyTypes,
|
||||||
|
supplyTypeFilterOptions
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[boardFilters, floorFilterOptions, supplyTypeFilterOptions]
|
||||||
|
);
|
||||||
|
const visibleBoards = useMemo(
|
||||||
|
() =>
|
||||||
|
filterDistributionBoards(
|
||||||
|
boards,
|
||||||
|
effectiveBoardFilters,
|
||||||
|
new Map(floors.map((floor) => [floor.id, floor.name]))
|
||||||
|
),
|
||||||
|
[boards, effectiveBoardFilters, floors]
|
||||||
|
);
|
||||||
const circuitListByBoardId = useMemo(
|
const circuitListByBoardId = useMemo(
|
||||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||||
[circuitLists]
|
[circuitLists]
|
||||||
|
|
@ -850,22 +963,28 @@ export default function ProjectDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
onClick={() => setIsRevitCsvOpen(true)}
|
onClick={() => setIsRevitCsvOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Revit-CSV
|
Revit-CSV
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
onClick={() => setIsProjectSettingsOpen(true)}
|
onClick={() => setIsProjectSettingsOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<Settings aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Projekteinstellungen
|
Projekteinstellungen
|
||||||
</button>
|
</button>
|
||||||
<Link className="btn btn-outline-secondary" href="/projects">
|
<Link
|
||||||
|
className="btn btn-outline-secondary btn-with-icon"
|
||||||
|
href="/projects"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Zur Projektübersicht
|
Zur Projektübersicht
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -910,14 +1029,52 @@ export default function ProjectDetailPage() {
|
||||||
<span>Verteilungen</span>
|
<span>Verteilungen</span>
|
||||||
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="overview-actions">
|
||||||
className="btn btn-sm btn-primary"
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
label="Verteilung hinzufügen"
|
||||||
onClick={openBoardCreator}
|
onClick={openBoardCreator}
|
||||||
type="button"
|
showLabel
|
||||||
>
|
variant="primary"
|
||||||
Verteilung hinzufügen
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleFilterPanel
|
||||||
|
isFilterActive={isDistributionBoardFilterActive(
|
||||||
|
effectiveBoardFilters
|
||||||
|
)}
|
||||||
|
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
|
||||||
|
storageKey={boardFilterCollapsedStorageKey}
|
||||||
|
totalCount={boards.length}
|
||||||
|
visibleCount={visibleBoards.length}
|
||||||
|
>
|
||||||
|
<FilterSearchField
|
||||||
|
label="Suche"
|
||||||
|
onChange={(query) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, query }))
|
||||||
|
}
|
||||||
|
placeholder="Verteilung oder Etage …"
|
||||||
|
value={boardFilters.query}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
emptyLabel="Keine Etagen vorhanden"
|
||||||
|
label="Etage"
|
||||||
|
onChange={(floorIds) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, floorIds }))
|
||||||
|
}
|
||||||
|
options={floorFilterOptions}
|
||||||
|
selected={effectiveBoardFilters.floorIds}
|
||||||
|
/>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
emptyLabel="Keine Netzarten vorhanden"
|
||||||
|
label="Netzart"
|
||||||
|
onChange={(supplyTypes) =>
|
||||||
|
setBoardFilters((current) => ({ ...current, supplyTypes }))
|
||||||
|
}
|
||||||
|
options={supplyTypeFilterOptions}
|
||||||
|
selected={effectiveBoardFilters.supplyTypes}
|
||||||
|
/>
|
||||||
|
</CollapsibleFilterPanel>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-hover align-middle mb-0">
|
<table className="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -930,7 +1087,7 @@ export default function ProjectDetailPage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{boards.map((board) => {
|
{visibleBoards.map((board) => {
|
||||||
const circuitList = circuitListByBoardId.get(board.id);
|
const circuitList = circuitListByBoardId.get(board.id);
|
||||||
return (
|
return (
|
||||||
<tr key={board.id}>
|
<tr key={board.id}>
|
||||||
|
|
@ -957,22 +1114,21 @@ export default function ProjectDetailPage() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
|
<div className="overview-actions justify-content-end">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={Pencil}
|
||||||
|
label="Bearbeiten"
|
||||||
onClick={() => openBoardEditor(board)}
|
onClick={() => openBoardEditor(board)}
|
||||||
type="button"
|
/>
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
{circuitList ? (
|
{circuitList ? (
|
||||||
<Link
|
<IconLink
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||||||
>
|
icon={ListTree}
|
||||||
Stromkreisliste öffnen
|
label="Stromkreisliste öffnen"
|
||||||
</Link>
|
showLabel
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-secondary small align-self-center">
|
<span className="text-secondary small align-self-center">
|
||||||
Keine Stromkreisliste vorhanden
|
Keine Stromkreisliste vorhanden
|
||||||
|
|
@ -992,6 +1148,11 @@ export default function ProjectDetailPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{boards.length > 0 && visibleBoards.length === 0 ? (
|
||||||
|
<p className="text-center text-secondary py-4 mb-0">
|
||||||
|
Keine Verteilungen entsprechen den Filtern.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1085,24 +1246,58 @@ export default function ProjectDetailPage() {
|
||||||
<span>Projektgeräte</span>
|
<span>Projektgeräte</span>
|
||||||
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="overview-actions">
|
||||||
<input
|
<IconButton
|
||||||
aria-label="Projektgeräte durchsuchen"
|
icon={Plus}
|
||||||
className="form-control form-control-sm"
|
label="Gerät hinzufügen"
|
||||||
onChange={(event) => setProjectDeviceQuery(event.target.value)}
|
|
||||||
placeholder="Geräte durchsuchen"
|
|
||||||
type="search"
|
|
||||||
value={projectDeviceQuery}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary text-nowrap"
|
|
||||||
onClick={() => setIsProjectDeviceModalOpen(true)}
|
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||||||
type="button"
|
showLabel
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleFilterPanel
|
||||||
|
isFilterActive={isProjectDeviceFilterActive(
|
||||||
|
effectiveProjectDeviceFilters
|
||||||
|
)}
|
||||||
|
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
|
||||||
|
storageKey={projectDeviceFilterCollapsedStorageKey}
|
||||||
|
totalCount={projectDevices.length}
|
||||||
|
visibleCount={visibleProjectDevices.length}
|
||||||
>
|
>
|
||||||
Gerät hinzufügen
|
<FilterSearchField
|
||||||
</button>
|
label="Suche"
|
||||||
</div>
|
onChange={(query) =>
|
||||||
</div>
|
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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped align-middle mb-0">
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -1121,11 +1316,13 @@ export default function ProjectDetailPage() {
|
||||||
<strong>{device.displayName}</strong>
|
<strong>{device.displayName}</strong>
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.name}
|
{device.name}
|
||||||
{device.category ? ` · ${device.category}` : ""}
|
{device.category
|
||||||
|
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
{projectDevicePhaseTypeLabels[device.phaseType]}
|
||||||
{device.connectionKind ? (
|
{device.connectionKind ? (
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.connectionKind}
|
{device.connectionKind}
|
||||||
|
|
@ -1141,39 +1338,32 @@ export default function ProjectDetailPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{device.costGroup ?? "-"}</td>
|
<td>{device.costGroup ?? "-"}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
|
<div className="overview-actions justify-content-end">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-sm btn-outline-secondary"
|
disabled={isSaving}
|
||||||
type="button"
|
icon={Pencil}
|
||||||
|
label="Bearbeiten"
|
||||||
onClick={() => setEditingProjectDevice(device)}
|
onClick={() => setEditingProjectDevice(device)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Link2}
|
||||||
Bearbeiten
|
label="Verknüpfungen"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Library}
|
||||||
Verknüpfungen
|
label="Nach global kopieren"
|
||||||
</button>
|
onClick={() => void handleCopyProjectToGlobal(device.id)}
|
||||||
<button
|
/>
|
||||||
className="btn btn-sm btn-outline-secondary"
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
icon={Trash2}
|
||||||
Nach global kopieren
|
label="Löschen"
|
||||||
</button>
|
onClick={() => void handleDeleteProjectDevice(device.id)}
|
||||||
<button
|
variant="danger"
|
||||||
className="btn btn-sm btn-outline-danger"
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteProjectDevice(device.id)}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1182,7 +1372,7 @@ export default function ProjectDetailPage() {
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-center text-secondary py-4">
|
<td colSpan={5} className="text-center text-secondary py-4">
|
||||||
{projectDevices.length
|
{projectDevices.length
|
||||||
? "Keine Projektgeräte entsprechen der Suche."
|
? "Keine Projektgeräte entsprechen den Filtern."
|
||||||
: "Noch keine Projektgeräte vorhanden."}
|
: "Noch keine Projektgeräte vorhanden."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1199,9 +1389,13 @@ export default function ProjectDetailPage() {
|
||||||
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
<div className="overview-actions">
|
||||||
Schließen
|
<IconButton
|
||||||
</button>
|
icon={X}
|
||||||
|
label="Schließen"
|
||||||
|
onClick={() => setSyncPreview(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-3 mb-3">
|
<div className="d-flex flex-wrap gap-3 mb-3">
|
||||||
|
|
@ -1280,23 +1474,23 @@ export default function ProjectDetailPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="overview-actions">
|
||||||
<button
|
<IconButton
|
||||||
className="btn btn-primary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||||
>
|
icon={RefreshCw}
|
||||||
Auswahl synchronisieren
|
label="Auswahl synchronisieren"
|
||||||
</button>
|
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||||||
<button
|
showLabel
|
||||||
className="btn btn-outline-danger"
|
variant="primary"
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
<IconButton
|
||||||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||||
>
|
icon={Unlink}
|
||||||
Auswahl trennen
|
label="Auswahl trennen"
|
||||||
</button>
|
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||||||
|
showLabel
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
120
src/frontend/components/filter-panel.tsx
Normal file
120
src/frontend/components/filter-panel.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
|
||||||
|
import React, { useEffect, useId, useState, type ReactNode } from "react";
|
||||||
|
import { IconButton } from "./icon-button";
|
||||||
|
|
||||||
|
function readCollapsed(storageKey: string) {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(storageKey) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable (private mode, blocked): keep the in-memory state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible filter area inside a card (PM app style): a small header row
|
||||||
|
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
|
||||||
|
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
|
||||||
|
* button while a filter is active. Only the bar collapses, never the card.
|
||||||
|
* The collapsed state is remembered per `storageKey` (default: expanded).
|
||||||
|
*/
|
||||||
|
export function CollapsibleFilterPanel({
|
||||||
|
storageKey,
|
||||||
|
isFilterActive,
|
||||||
|
visibleCount,
|
||||||
|
totalCount,
|
||||||
|
onReset,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
storageKey: string;
|
||||||
|
isFilterActive: boolean;
|
||||||
|
visibleCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
onReset: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const barId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsCollapsed(readCollapsed(storageKey));
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
const next = !isCollapsed;
|
||||||
|
setIsCollapsed(next);
|
||||||
|
writeCollapsed(storageKey, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-panel-header">
|
||||||
|
<IconButton
|
||||||
|
ariaControls={barId}
|
||||||
|
ariaExpanded={!isCollapsed}
|
||||||
|
className="filter-panel-toggle"
|
||||||
|
icon={isCollapsed ? ChevronRight : ChevronDown}
|
||||||
|
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
/>
|
||||||
|
<span className="filter-panel-label">Filter</span>
|
||||||
|
{isCollapsed && isFilterActive ? (
|
||||||
|
<span className="filter-panel-badge">Filter aktiv</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-live="polite" className="filter-panel-count">
|
||||||
|
{visibleCount} von {totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? null : (
|
||||||
|
<div className="filter-bar" id={barId}>
|
||||||
|
{children}
|
||||||
|
{isFilterActive ? (
|
||||||
|
<IconButton
|
||||||
|
className="filter-bar-reset"
|
||||||
|
icon={FilterX}
|
||||||
|
label="Zurücksetzen"
|
||||||
|
onClick={onReset}
|
||||||
|
showLabel
|
||||||
|
title="Alle Filter zurücksetzen"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search field for a filter bar. */
|
||||||
|
export function FilterSearchField({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="filter-bar-field">
|
||||||
|
<span className="filter-bar-label">{label}</span>
|
||||||
|
<input
|
||||||
|
className="filter-bar-search"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import React, { type ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact editor button with a lucide icon. `label` is always the accessible
|
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||||
|
|
|
||||||
40
src/frontend/components/icon-link.tsx
Normal file
40
src/frontend/components/icon-link.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
||||||
|
* look. `label` is always the accessible name and, unless `title` is given,
|
||||||
|
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
||||||
|
*/
|
||||||
|
export function IconLink({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
variant = "default",
|
||||||
|
showLabel = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
href: string;
|
||||||
|
variant?: "default" | "danger" | "primary";
|
||||||
|
showLabel?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
aria-label={showLabel ? undefined : label}
|
||||||
|
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||||
|
href={href}
|
||||||
|
title={title ?? label}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||||
|
{showLabel ? <span>{label}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import React, { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
toggleFilterValue,
|
||||||
|
type FilterOption,
|
||||||
|
} from "../utils/overview-filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
||||||
|
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
||||||
|
* click and Escape.
|
||||||
|
*/
|
||||||
|
export function MultiSelectDropdown({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
emptyLabel = "Keine Einträge vorhanden",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: readonly FilterOption[];
|
||||||
|
selected: readonly string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const labelId = useId();
|
||||||
|
const valueId = useId();
|
||||||
|
const panelId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
toggleRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
selected.length === 0
|
||||||
|
? "Alle"
|
||||||
|
: selected.length === 1
|
||||||
|
? options.find((option) => option.id === selected[0])?.label ??
|
||||||
|
"1 ausgewählt"
|
||||||
|
: `${selected.length} ausgewählt`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-dropdown" ref={containerRef}>
|
||||||
|
<span className="filter-bar-label" id={labelId}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-labelledby={`${labelId} ${valueId}`}
|
||||||
|
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
ref={toggleRef}
|
||||||
|
title={summary}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="filter-dropdown-value" id={valueId}>
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
className="filter-dropdown-panel"
|
||||||
|
id={panelId}
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="filter-dropdown-option filter-dropdown-option-all">
|
||||||
|
<input
|
||||||
|
checked={selected.length === 0}
|
||||||
|
onChange={() => onChange([])}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Alle
|
||||||
|
</label>
|
||||||
|
{options.map((option) => (
|
||||||
|
<label className="filter-dropdown-option" key={option.id}>
|
||||||
|
<input
|
||||||
|
checked={selected.includes(option.id)}
|
||||||
|
onChange={() =>
|
||||||
|
onChange(toggleFilterValue(selected, option.id))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,14 @@ import {
|
||||||
getProjectSnapshotRevisionDescription,
|
getProjectSnapshotRevisionDescription,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../utils/project-version-history";
|
} from "../utils/project-version-history";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Redo2,
|
||||||
|
RotateCcw,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||||
|
|
||||||
interface ProjectVersionHistoryProps {
|
interface ProjectVersionHistoryProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
|
||||||
<span className="badge text-bg-secondary">
|
<span className="badge text-bg-secondary">
|
||||||
Aktuelle Revision {currentRevision}
|
Aktuelle Revision {currentRevision}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="overview-actions">
|
||||||
className="btn btn-sm btn-outline-secondary"
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
isHistoryLoading ||
|
isHistoryLoading ||
|
||||||
!historyIsCurrent ||
|
!historyIsCurrent ||
|
||||||
!history?.undoDepth
|
!history?.undoDepth
|
||||||
}
|
}
|
||||||
|
icon={Undo2}
|
||||||
|
label="Rückgängig"
|
||||||
onClick={() => void handleHistoryCommand("undo")}
|
onClick={() => void handleHistoryCommand("undo")}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
history?.undoDepth
|
history?.undoDepth
|
||||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||||
: "Keine Änderung zum Rückgängigmachen"
|
: "Keine Änderung zum Rückgängigmachen"
|
||||||
}
|
}
|
||||||
type="button"
|
/>
|
||||||
>
|
<IconButton
|
||||||
Rückgängig
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
isHistoryLoading ||
|
isHistoryLoading ||
|
||||||
!historyIsCurrent ||
|
!historyIsCurrent ||
|
||||||
!history?.redoDepth
|
!history?.redoDepth
|
||||||
}
|
}
|
||||||
|
icon={Redo2}
|
||||||
|
label="Wiederholen"
|
||||||
onClick={() => void handleHistoryCommand("redo")}
|
onClick={() => void handleHistoryCommand("redo")}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
history?.redoDepth
|
history?.redoDepth
|
||||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||||
: "Keine Änderung zum Wiederholen"
|
: "Keine Änderung zum Wiederholen"
|
||||||
}
|
}
|
||||||
type="button"
|
/>
|
||||||
>
|
<IconButtonSeparator />
|
||||||
Wiederholen
|
<IconButton
|
||||||
</button>
|
ariaExpanded={isOpen}
|
||||||
<button
|
icon={isOpen ? ChevronUp : ChevronDown}
|
||||||
aria-expanded={isOpen}
|
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
type="button"
|
showLabel
|
||||||
>
|
/>
|
||||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<button
|
<div className="overview-actions justify-content-end">
|
||||||
className="btn btn-sm btn-outline-danger"
|
<IconButton
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Wiederherstellen"
|
||||||
onClick={() => setRestoreCandidate(snapshot)}
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
type="button"
|
variant="danger"
|
||||||
>
|
/>
|
||||||
Wiederherstellen
|
</div>
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
239
src/frontend/utils/overview-filters.ts
Normal file
239
src/frontend/utils/overview-filters.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDistributionBoards<
|
||||||
|
Board extends Pick<DistributionBoardDto, "name" | "floorId" | "supplyType">,
|
||||||
|
>(
|
||||||
|
boards: readonly Board[],
|
||||||
|
filters: DistributionBoardFilters,
|
||||||
|
floorNameById: ReadonlyMap<string, string>
|
||||||
|
): Board[] {
|
||||||
|
const query = normalizeFilterQuery(filters.query);
|
||||||
|
return boards.filter(
|
||||||
|
(board) =>
|
||||||
|
matchesQuery(query, [
|
||||||
|
board.name,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
248
tests/overview-filters.test.ts
Normal file
248
tests/overview-filters.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
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("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