forked from jappel/leistungsbilanz-ts
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:
commit
2e223be8b4
11 changed files with 1457 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]
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue