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