From 837b29c7c55d6ba1289ab711cc94f73da12ef5c3 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 12:55:38 +0200 Subject: [PATCH] Add icon actions and collapsible filters to the project overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 8 + docs/current-architecture.md | 6 + src/app/globals.css | 257 +++++++++++ src/app/projects/[projectId]/page.tsx | 400 +++++++++++++----- src/frontend/components/filter-panel.tsx | 120 ++++++ src/frontend/components/icon-button.tsx | 2 +- src/frontend/components/icon-link.tsx | 40 ++ .../components/multi-select-dropdown.tsx | 127 ++++++ .../components/project-version-history.tsx | 113 ++--- src/frontend/utils/overview-filters.ts | 239 +++++++++++ tests/overview-filters.test.ts | 248 +++++++++++ 11 files changed, 1404 insertions(+), 156 deletions(-) create mode 100644 src/frontend/components/filter-panel.tsx create mode 100644 src/frontend/components/icon-link.tsx create mode 100644 src/frontend/components/multi-select-dropdown.tsx create mode 100644 src/frontend/utils/overview-filters.ts create mode 100644 tests/overview-filters.test.ts diff --git a/AGENTS.md b/AGENTS.md index ab8393e..1eb5661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. Header labels break only between words and put a trailing unit such as `[kW]` on a second line. +The project overview uses the same rule: row actions are icon-only +`IconButton`s, card-header and panel actions show icon plus text, navigation +uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons +stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter +through a collapsible PM-style filter bar (`CollapsibleFilterPanel`, +`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only +the bar collapses, its state is kept per card in `localStorage`, a collapsed +active filter shows "Filter aktiv". ## Bootstrap / Styling Rule diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 92efc98..189dffc 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -252,6 +252,12 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, damit Änderungen weiterhin niemals still synchronisiert werden. +Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare +Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw. +Kategorie/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 Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, diff --git a/src/app/globals.css b/src/app/globals.css index fe81d07..dd26b0c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -381,6 +381,263 @@ a { font-weight: 600; } +/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ───── + Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen + kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der + PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an + die Tokens dieser App. */ + +.btn-with-icon { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.overview-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; +} + +.overview-actions .icon-button { + min-width: 1.9rem; + min-height: 1.85rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.overview-actions .icon-button:disabled { + opacity: 0.45; +} + +.overview-actions .icon-button-primary { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +.overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-primary-dark); + background: var(--color-primary-dark); + color: #fff; +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary { + border-color: var(--color-accent); + background: var(--color-accent); +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-accent-pale); + background: var(--color-accent-pale); + color: var(--color-primary-deep); +} + +.overview-actions .icon-button-danger { + color: var(--color-danger); +} + +.filter-panel { + padding: 0.55rem 1rem 0.7rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.filter-panel-header { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-panel-header .filter-panel-toggle { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: none; + color: var(--color-ink-soft); +} + +.filter-panel-header .filter-panel-toggle:hover { + background: var(--color-surface); +} + +.filter-panel-label, +.filter-bar-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-ink-soft); +} + +.filter-panel-badge { + padding: 3px 10px; + border-radius: 999px; + background: #fbe9d9; + color: var(--color-attention-dark); + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; +} + +:root[data-bs-theme="dark"] .filter-panel-badge { + background: #3a2e1c; + color: #f0b880; +} + +.filter-panel-count { + margin-left: auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-sm) var(--space-md); + margin-top: 0.5rem; +} + +.filter-bar-field, +.filter-dropdown { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; +} + +.filter-bar-search, +.filter-dropdown-toggle { + min-height: 2.1rem; + padding: 0.35rem 0.65rem; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font: inherit; + font-size: 0.82rem; +} + +.filter-bar-search { + width: 15em; +} + +.filter-bar-search:focus, +.filter-dropdown-toggle:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; + border-color: var(--color-accent); +} + +.filter-dropdown-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 11em; + max-width: 18em; + font-weight: 500; + cursor: pointer; +} + +.filter-dropdown-toggle:hover, +.filter-dropdown-toggle.is-active { + border-color: var(--color-accent); +} + +.filter-dropdown-toggle.is-active { + color: var(--color-primary); +} + +:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active { + color: var(--color-accent-pale); +} + +.filter-dropdown-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-dropdown-toggle svg { + flex: none; + color: var(--color-ink-soft); +} + +.filter-dropdown-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + padding: 8px; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + box-shadow: 0 6px 20px var(--shadow-menu); +} + +.filter-dropdown-option { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 8px; + border-radius: 6px; + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 400; + cursor: pointer; +} + +.filter-dropdown-option:hover { + background: var(--color-surface); +} + +.filter-dropdown-option input { + accent-color: var(--color-primary); +} + +.filter-dropdown-option-all { + margin-bottom: 2px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); + border-radius: 6px 6px 0 0; +} + +.filter-dropdown-empty { + padding: 6px 8px; + color: var(--color-ink-soft); + font-size: 0.8rem; +} + +.filter-bar .filter-bar-reset { + min-height: 2.1rem; + padding: 0.3rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; +} + /* ── KPI-Kacheln ─────────────────────────────────────────────────────── */ .kpi-grid { diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index fa342ea..f937804 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -3,6 +3,47 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + FileUp, + Library, + Link2, + ListTree, + Pencil, + Plus, + RefreshCw, + Settings, + Trash2, + Unlink, + X, +} from "lucide-react"; +import { IconButton } from "../../../frontend/components/icon-button"; +import { IconLink } from "../../../frontend/components/icon-link"; +import { + CollapsibleFilterPanel, + FilterSearchField, +} from "../../../frontend/components/filter-panel"; +import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown"; +import { + circuitGroupCategories, + circuitGroupCategoryLabels, +} from "../../../shared/constants/circuit-group"; +import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board"; +import { + buildCostGroupFilterOptions, + buildFloorFilterOptions, + buildSupplyTypeFilterOptions, + emptyDistributionBoardFilters, + emptyProjectDeviceFilters, + filterDistributionBoards, + filterProjectDevices, + isDistributionBoardFilterActive, + isProjectDeviceFilterActive, + retainAvailableFilterValues, + type DistributionBoardFilters, + type FilterOption, + type ProjectDeviceFilters, +} from "../../../frontend/utils/overview-filters"; import { copyGlobalDeviceToProject, copyDistributionBoard, @@ -77,6 +118,29 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; +const projectDeviceCategoryFilterOptions: FilterOption[] = + circuitGroupCategories.map((category) => ({ + id: category, + label: circuitGroupCategoryLabels[category], + })); + +const projectDevicePhaseTypeLabels: Record = { + single_phase: "1-phasig", + three_phase: "3-phasig", +}; + +const projectDevicePhaseTypeFilterOptions: FilterOption[] = ( + ["single_phase", "three_phase"] as const +).map((phaseType) => ({ + id: phaseType, + label: projectDevicePhaseTypeLabels[phaseType], +})); + +const boardFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:distribution-boards"; +const projectDeviceFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:project-devices"; + export default function ProjectDetailPage() { const params = useParams<{ projectId: string }>(); const [projectId, setProjectId] = useState(""); @@ -113,7 +177,11 @@ export default function ProjectDetailPage() { useState(false); const [editingProjectDevice, setEditingProjectDevice] = useState(null); - const [projectDeviceQuery, setProjectDeviceQuery] = useState(""); + const [boardFilters, setBoardFilters] = useState( + emptyDistributionBoardFilters + ); + const [projectDeviceFilters, setProjectDeviceFilters] = + useState(emptyProjectDeviceFilters); const [syncPreview, setSyncPreview] = useState(null); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); @@ -164,22 +232,67 @@ export default function ProjectDetailPage() { const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); - const visibleProjectDevices = useMemo(() => { - const query = projectDeviceQuery.trim().toLocaleLowerCase("de"); - if (!query) { - return projectDevices; - } - return projectDevices.filter((device) => - [ - device.name, - device.displayName, - device.category, - device.connectionKind, - device.costGroup, - ].some((value) => value?.toLocaleLowerCase("de").includes(query)) - ); - }, [projectDeviceQuery, projectDevices]); + const costGroupFilterOptions = useMemo( + () => buildCostGroupFilterOptions(projectDevices), + [projectDevices] + ); + const effectiveProjectDeviceFilters = useMemo( + () => ({ + ...projectDeviceFilters, + costGroups: retainAvailableFilterValues( + projectDeviceFilters.costGroups, + costGroupFilterOptions + ), + }), + [costGroupFilterOptions, projectDeviceFilters] + ); + const visibleProjectDevices = useMemo( + () => + filterProjectDevices( + projectDevices, + effectiveProjectDeviceFilters, + circuitGroupCategoryLabels + ), + [effectiveProjectDeviceFilters, projectDevices] + ); const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]); + const floorFilterOptions = useMemo( + () => buildFloorFilterOptions(floors), + [floors] + ); + const supplyTypeFilterOptions = useMemo( + () => + buildSupplyTypeFilterOptions( + distributionBoardSupplyTypes, + project?.enabledDistributionBoardSupplyTypes ?? [], + boards, + distributionBoardSupplyTypeLabels + ), + [boards, project?.enabledDistributionBoardSupplyTypes] + ); + const effectiveBoardFilters = useMemo( + () => ({ + ...boardFilters, + floorIds: retainAvailableFilterValues( + boardFilters.floorIds, + floorFilterOptions + ), + supplyTypes: retainAvailableFilterValues( + boardFilters.supplyTypes, + supplyTypeFilterOptions + ), + }), + [boardFilters, floorFilterOptions, supplyTypeFilterOptions] + ); + const visibleBoards = useMemo( + () => + filterDistributionBoards( + boards, + effectiveBoardFilters, + new Map(floors.map((floor) => [floor.id, floor.name])) + ), + [boards, effectiveBoardFilters, floors] + ); const circuitListByBoardId = useMemo( () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), [circuitLists] @@ -850,22 +963,28 @@ export default function ProjectDetailPage() {
- + +
@@ -910,14 +1029,52 @@ export default function ProjectDetailPage() { Verteilungen {boardCount} - +
+ +
+ setBoardFilters(emptyDistributionBoardFilters)} + storageKey={boardFilterCollapsedStorageKey} + totalCount={boards.length} + visibleCount={visibleBoards.length} + > + + setBoardFilters((current) => ({ ...current, query })) + } + placeholder="Verteilung oder Etage …" + value={boardFilters.query} + /> + + setBoardFilters((current) => ({ ...current, floorIds })) + } + options={floorFilterOptions} + selected={effectiveBoardFilters.floorIds} + /> + + setBoardFilters((current) => ({ ...current, supplyTypes })) + } + options={supplyTypeFilterOptions} + selected={effectiveBoardFilters.supplyTypes} + /> +
@@ -930,7 +1087,7 @@ export default function ProjectDetailPage() { - {boards.map((board) => { + {visibleBoards.map((board) => { const circuitList = circuitListByBoardId.get(board.id); return ( @@ -957,22 +1114,21 @@ export default function ProjectDetailPage() { )}
-
- + /> {circuitList ? ( - - Stromkreisliste öffnen - + icon={ListTree} + label="Stromkreisliste öffnen" + showLabel + variant="primary" + /> ) : ( Keine Stromkreisliste vorhanden @@ -992,6 +1148,11 @@ export default function ProjectDetailPage() { ) : null}
+ {boards.length > 0 && visibleBoards.length === 0 ? ( +

+ Keine Verteilungen entsprechen den Filtern. +

+ ) : null}
@@ -1085,24 +1246,58 @@ export default function ProjectDetailPage() { Projektgeräte {projectDeviceCount} -
- setProjectDeviceQuery(event.target.value)} - placeholder="Geräte durchsuchen" - type="search" - value={projectDeviceQuery} - /> - + showLabel + variant="primary" + />
+ setProjectDeviceFilters(emptyProjectDeviceFilters)} + storageKey={projectDeviceFilterCollapsedStorageKey} + totalCount={projectDevices.length} + visibleCount={visibleProjectDevices.length} + > + + setProjectDeviceFilters((current) => ({ ...current, query })) + } + placeholder="Gerät, Anschlussart, KG …" + value={projectDeviceFilters.query} + /> + + setProjectDeviceFilters((current) => ({ ...current, categories })) + } + options={projectDeviceCategoryFilterOptions} + selected={effectiveProjectDeviceFilters.categories} + /> + + setProjectDeviceFilters((current) => ({ ...current, phaseTypes })) + } + options={projectDevicePhaseTypeFilterOptions} + selected={effectiveProjectDeviceFilters.phaseTypes} + /> + + setProjectDeviceFilters((current) => ({ ...current, costGroups })) + } + options={costGroupFilterOptions} + selected={effectiveProjectDeviceFilters.costGroups} + /> +
@@ -1121,11 +1316,13 @@ export default function ProjectDetailPage() { {device.displayName}
{device.name} - {device.category ? ` · ${device.category}` : ""} + {device.category + ? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}` + : ""}
@@ -1182,7 +1372,7 @@ export default function ProjectDetailPage() { @@ -1199,9 +1389,13 @@ export default function ProjectDetailPage() { Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.

- +
+ setSyncPreview(null)} + /> +
@@ -1280,23 +1474,23 @@ export default function ProjectDetailPage() {
- {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"} + {projectDevicePhaseTypeLabels[device.phaseType]} {device.connectionKind ? (
{device.connectionKind} @@ -1141,39 +1338,32 @@ export default function ProjectDetailPage() {
{device.costGroup ?? "-"} -
- - - - + icon={Trash2} + label="Löschen" + onClick={() => void handleDeleteProjectDevice(device.id)} + variant="danger" + />
{projectDevices.length - ? "Keine Projektgeräte entsprechen der Suche." + ? "Keine Projektgeräte entsprechen den Filtern." : "Noch keine Projektgeräte vorhanden."}
-
- - + icon={Unlink} + label="Auswahl trennen" + onClick={() => void handleDisconnectProjectDeviceRows()} + showLabel + variant="danger" + />
) : null} diff --git a/src/frontend/components/filter-panel.tsx b/src/frontend/components/filter-panel.tsx new file mode 100644 index 0000000..afa1fde --- /dev/null +++ b/src/frontend/components/filter-panel.tsx @@ -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 ( +
+
+ + Filter + {isCollapsed && isFilterActive ? ( + Filter aktiv + ) : null} + + {visibleCount} von {totalCount} + +
+ {isCollapsed ? null : ( +
+ {children} + {isFilterActive ? ( + + ) : null} +
+ )} +
+ ); +} + +/** Search field for a filter bar. */ +export function FilterSearchField({ + label, + placeholder, + value, + onChange, +}: { + label: string; + placeholder: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/src/frontend/components/icon-button.tsx b/src/frontend/components/icon-button.tsx index 605036d..4973029 100644 --- a/src/frontend/components/icon-button.tsx +++ b/src/frontend/components/icon-button.tsx @@ -1,7 +1,7 @@ "use client"; 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 diff --git a/src/frontend/components/icon-link.tsx b/src/frontend/components/icon-link.tsx new file mode 100644 index 0000000..f5453c2 --- /dev/null +++ b/src/frontend/components/icon-link.tsx @@ -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 ( + +