Integrate project overview filters and icon actions

Document filter combinations, reset and browser persistence. Keep the search hint applicable before board information fields are introduced.

Validation: 20 focused tests, 431 total tests, API/web builds, scripts and Next typechecks, and diff checks passed. GUI retest skipped as authorized based on the colleague's prior testing.
This commit is contained in:
Julian Appel 2026-09-26 21:16:48 +02:00
commit 2e223be8b4
11 changed files with 1457 additions and 156 deletions

View file

@ -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 {

View file

@ -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<ProjectDeviceSyncField, string> = {
remark: "Bemerkung",
};
const projectDeviceCategoryFilterOptions: FilterOption[] =
circuitGroupCategories.map((category) => ({
id: category,
label: circuitGroupCategoryLabels[category],
}));
const projectDevicePhaseTypeLabels: Record<ProjectDeviceDto["phaseType"], string> = {
single_phase: "1-phasig",
three_phase: "3-phasig",
};
const projectDevicePhaseTypeFilterOptions: FilterOption[] = (
["single_phase", "three_phase"] as const
).map((phaseType) => ({
id: phaseType,
label: projectDevicePhaseTypeLabels[phaseType],
}));
const boardFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices";
export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>();
const [projectId, setProjectId] = useState("");
@ -113,7 +177,11 @@ export default function ProjectDetailPage() {
useState(false);
const [editingProjectDevice, setEditingProjectDevice] =
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 [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
@ -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>(
() => ({
...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<DistributionBoardFilters>(
() => ({
...boardFilters,
floorIds: retainAvailableFilterValues(
boardFilters.floorIds,
floorFilterOptions
),
supplyTypes: retainAvailableFilterValues(
boardFilters.supplyTypes,
supplyTypeFilterOptions
),
}),
[boardFilters, floorFilterOptions, supplyTypeFilterOptions]
);
const visibleBoards = useMemo(
() =>
filterDistributionBoards(
boards,
effectiveBoardFilters,
new Map(floors.map((floor) => [floor.id, floor.name]))
),
[boards, effectiveBoardFilters, floors]
);
const circuitListByBoardId = useMemo(
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
@ -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() {
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV
</button>
<button
className="btn btn-outline-primary"
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
<Settings aria-hidden="true" size={16} strokeWidth={2} />
Projekteinstellungen
</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
</Link>
</div>
@ -910,14 +1034,52 @@ export default function ProjectDetailPage() {
<span>Verteilungen</span>
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<button
className="btn btn-sm btn-primary"
onClick={openBoardCreator}
type="button"
>
Verteilung hinzufügen
</button>
<div className="overview-actions">
<IconButton
icon={Plus}
label="Verteilung hinzufügen"
onClick={openBoardCreator}
showLabel
variant="primary"
/>
</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="Verteilungen durchsuchen …"
value={boardFilters.query}
/>
<MultiSelectDropdown
emptyLabel="Keine Etagen vorhanden"
label="Etage"
onChange={(floorIds) =>
setBoardFilters((current) => ({ ...current, floorIds }))
}
options={floorFilterOptions}
selected={effectiveBoardFilters.floorIds}
/>
<MultiSelectDropdown
emptyLabel="Keine Netzarten vorhanden"
label="Netzart"
onChange={(supplyTypes) =>
setBoardFilters((current) => ({ ...current, supplyTypes }))
}
options={supplyTypeFilterOptions}
selected={effectiveBoardFilters.supplyTypes}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
@ -930,7 +1092,7 @@ export default function ProjectDetailPage() {
</tr>
</thead>
<tbody>
{boards.map((board) => {
{visibleBoards.map((board) => {
const circuitList = circuitListByBoardId.get(board.id);
return (
<tr key={board.id}>
@ -957,22 +1119,21 @@ export default function ProjectDetailPage() {
)}
</td>
<td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button
className="btn btn-sm btn-outline-secondary"
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => openBoardEditor(board)}
type="button"
>
Bearbeiten
</button>
/>
{circuitList ? (
<Link
className="btn btn-sm btn-primary"
<IconLink
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
>
Stromkreisliste öffnen
</Link>
icon={ListTree}
label="Stromkreisliste öffnen"
showLabel
variant="primary"
/>
) : (
<span className="text-secondary small align-self-center">
Keine Stromkreisliste vorhanden
@ -992,6 +1153,11 @@ export default function ProjectDetailPage() {
) : null}
</tbody>
</table>
{boards.length > 0 && visibleBoards.length === 0 ? (
<p className="text-center text-secondary py-4 mb-0">
Keine Verteilungen entsprechen den Filtern.
</p>
) : null}
</div>
</div>
</section>
@ -1085,24 +1251,58 @@ export default function ProjectDetailPage() {
<span>Projektgeräte</span>
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
</div>
<div className="d-flex gap-2">
<input
aria-label="Projektgeräte durchsuchen"
className="form-control form-control-sm"
onChange={(event) => setProjectDeviceQuery(event.target.value)}
placeholder="Geräte durchsuchen"
type="search"
value={projectDeviceQuery}
/>
<button
className="btn btn-sm btn-primary text-nowrap"
<div className="overview-actions">
<IconButton
icon={Plus}
label="Gerät hinzufügen"
onClick={() => setIsProjectDeviceModalOpen(true)}
type="button"
>
Gerät hinzufügen
</button>
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isProjectDeviceFilterActive(
effectiveProjectDeviceFilters
)}
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
storageKey={projectDeviceFilterCollapsedStorageKey}
totalCount={projectDevices.length}
visibleCount={visibleProjectDevices.length}
>
<FilterSearchField
label="Suche"
onChange={(query) =>
setProjectDeviceFilters((current) => ({ ...current, query }))
}
placeholder="Gerät, Anschlussart, KG …"
value={projectDeviceFilters.query}
/>
<MultiSelectDropdown
label="Kategorie"
onChange={(categories) =>
setProjectDeviceFilters((current) => ({ ...current, categories }))
}
options={projectDeviceCategoryFilterOptions}
selected={effectiveProjectDeviceFilters.categories}
/>
<MultiSelectDropdown
label="Anschluss"
onChange={(phaseTypes) =>
setProjectDeviceFilters((current) => ({ ...current, phaseTypes }))
}
options={projectDevicePhaseTypeFilterOptions}
selected={effectiveProjectDeviceFilters.phaseTypes}
/>
<MultiSelectDropdown
label="Kostengruppe"
onChange={(costGroups) =>
setProjectDeviceFilters((current) => ({ ...current, costGroups }))
}
options={costGroupFilterOptions}
selected={effectiveProjectDeviceFilters.costGroups}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
@ -1121,11 +1321,13 @@ export default function ProjectDetailPage() {
<strong>{device.displayName}</strong>
<div className="small text-secondary">
{device.name}
{device.category ? ` · ${device.category}` : ""}
{device.category
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
: ""}
</div>
</td>
<td>
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
{projectDevicePhaseTypeLabels[device.phaseType]}
{device.connectionKind ? (
<div className="small text-secondary">
{device.connectionKind}
@ -1141,39 +1343,32 @@ export default function ProjectDetailPage() {
</td>
<td>{device.costGroup ?? "-"}</td>
<td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
<button
className="btn btn-sm btn-outline-secondary"
type="button"
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => setEditingProjectDevice(device)}
/>
<IconButton
disabled={isSaving}
>
Bearbeiten
</button>
<button
className="btn btn-sm btn-outline-primary"
type="button"
icon={Link2}
label="Verknüpfungen"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
/>
<IconButton
disabled={isSaving}
>
Verknüpfungen
</button>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => handleCopyProjectToGlobal(device.id)}
icon={Library}
label="Nach global kopieren"
onClick={() => void handleCopyProjectToGlobal(device.id)}
/>
<IconButton
disabled={isSaving}
>
Nach global kopieren
</button>
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteProjectDevice(device.id)}
disabled={isSaving}
>
Löschen
</button>
icon={Trash2}
label="Löschen"
onClick={() => void handleDeleteProjectDevice(device.id)}
variant="danger"
/>
</div>
</td>
</tr>
@ -1182,7 +1377,7 @@ export default function ProjectDetailPage() {
<tr>
<td colSpan={5} className="text-center text-secondary py-4">
{projectDevices.length
? "Keine Projektgeräte entsprechen der Suche."
? "Keine Projektgeräte entsprechen den Filtern."
: "Noch keine Projektgeräte vorhanden."}
</td>
</tr>
@ -1199,9 +1394,13 @@ export default function ProjectDetailPage() {
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
Schließen
</button>
<div className="overview-actions">
<IconButton
icon={X}
label="Schließen"
onClick={() => setSyncPreview(null)}
/>
</div>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
@ -1280,23 +1479,23 @@ export default function ProjectDetailPage() {
</table>
</div>
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-primary"
type="button"
onClick={() => void handleSynchronizeProjectDeviceRows()}
<div className="overview-actions">
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
>
Auswahl synchronisieren
</button>
<button
className="btn btn-outline-danger"
type="button"
onClick={() => void handleDisconnectProjectDeviceRows()}
icon={RefreshCw}
label="Auswahl synchronisieren"
onClick={() => void handleSynchronizeProjectDeviceRows()}
showLabel
variant="primary"
/>
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0}
>
Auswahl trennen
</button>
icon={Unlink}
label="Auswahl trennen"
onClick={() => void handleDisconnectProjectDeviceRows()}
showLabel
variant="danger"
/>
</div>
</div>
) : null}