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

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