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
|
|
@ -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]
|
||||
|
|
@ -850,22 +963,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 +1029,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="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">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
|
|
@ -930,7 +1087,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 +1114,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 +1148,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 +1246,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 +1316,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 +1338,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 +1372,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 +1389,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 +1474,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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue