diff --git a/AGENTS.md b/AGENTS.md index 920a747..d8f963c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,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 b9ffba2..8349e75 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -261,6 +261,27 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, damit Änderungen weiterhin niemals still synchronisiert werden. +Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare +Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw. +Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die +Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der +Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor +`IconButton`/`IconLink`. +Die Verteilersuche berücksichtigt aktuell Name und Etagenname; die Gerätesuche +Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart +und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig. +Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter +und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende +Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise +„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei +der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte. +Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar. +Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe +und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine +Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts +erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den +bestehenden persistenten Projektbefehl. + `GET /api/projects/:projectId/export` verpackt denselben vollständigen Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, diff --git a/src/app/globals.css b/src/app/globals.css index b6128b0..76e5dd3 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..c08b72e 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] @@ -363,6 +476,11 @@ export default function ProjectDetailPage() { if (!projectId || !project) { return; } + const device = projectDevices.find((item) => item.id === projectDeviceId); + const deviceName = device?.displayName || device?.name || "Projektgerät"; + if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) { + return; + } setIsSaving(true); setError(null); try { @@ -850,22 +968,28 @@ export default function ProjectDetailPage() {
- + +
@@ -910,14 +1034,52 @@ export default function ProjectDetailPage() { Verteilungen {boardCount} - +
+ +
+ setBoardFilters(emptyDistributionBoardFilters)} + storageKey={boardFilterCollapsedStorageKey} + totalCount={boards.length} + visibleCount={visibleBoards.length} + > + + setBoardFilters((current) => ({ ...current, query })) + } + placeholder="Verteilungen durchsuchen …" + value={boardFilters.query} + /> + + setBoardFilters((current) => ({ ...current, floorIds })) + } + options={floorFilterOptions} + selected={effectiveBoardFilters.floorIds} + /> + + setBoardFilters((current) => ({ ...current, supplyTypes })) + } + options={supplyTypeFilterOptions} + selected={effectiveBoardFilters.supplyTypes} + /> +
@@ -930,7 +1092,7 @@ export default function ProjectDetailPage() { - {boards.map((board) => { + {visibleBoards.map((board) => { const circuitList = circuitListByBoardId.get(board.id); return ( @@ -957,22 +1119,21 @@ export default function ProjectDetailPage() { )}
-
- + /> {circuitList ? ( - - Stromkreisliste öffnen - + icon={ListTree} + label="Stromkreisliste öffnen" + showLabel + variant="primary" + /> ) : ( Keine Stromkreisliste vorhanden @@ -992,6 +1153,11 @@ export default function ProjectDetailPage() { ) : null}
+ {boards.length > 0 && visibleBoards.length === 0 ? ( +

+ Keine Verteilungen entsprechen den Filtern. +

+ ) : null}
@@ -1085,24 +1251,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 +1321,13 @@ export default function ProjectDetailPage() { {device.displayName}
{device.name} - {device.category ? ` · ${device.category}` : ""} + {device.category + ? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}` + : ""}
@@ -1182,7 +1377,7 @@ export default function ProjectDetailPage() { @@ -1199,9 +1394,13 @@ export default function ProjectDetailPage() { Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.

- +
+ setSyncPreview(null)} + /> +
@@ -1280,23 +1479,23 @@ export default function ProjectDetailPage() {
- {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"} + {projectDevicePhaseTypeLabels[device.phaseType]} {device.connectionKind ? (
{device.connectionKind} @@ -1141,39 +1343,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 ( + +