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
120
src/frontend/components/filter-panel.tsx
Normal file
120
src/frontend/components/filter-panel.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
|
||||
import React, { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { IconButton } from "./icon-button";
|
||||
|
||||
function readCollapsed(storageKey: string) {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// Storage unavailable (private mode, blocked): keep the in-memory state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible filter area inside a card (PM app style): a small header row
|
||||
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
|
||||
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
|
||||
* button while a filter is active. Only the bar collapses, never the card.
|
||||
* The collapsed state is remembered per `storageKey` (default: expanded).
|
||||
*/
|
||||
export function CollapsibleFilterPanel({
|
||||
storageKey,
|
||||
isFilterActive,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
onReset,
|
||||
children,
|
||||
}: {
|
||||
storageKey: string;
|
||||
isFilterActive: boolean;
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
onReset: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const barId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(readCollapsed(storageKey));
|
||||
}, [storageKey]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !isCollapsed;
|
||||
setIsCollapsed(next);
|
||||
writeCollapsed(storageKey, next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<IconButton
|
||||
ariaControls={barId}
|
||||
ariaExpanded={!isCollapsed}
|
||||
className="filter-panel-toggle"
|
||||
icon={isCollapsed ? ChevronRight : ChevronDown}
|
||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||
onClick={toggleCollapsed}
|
||||
/>
|
||||
<span className="filter-panel-label">Filter</span>
|
||||
{isCollapsed && isFilterActive ? (
|
||||
<span className="filter-panel-badge">Filter aktiv</span>
|
||||
) : null}
|
||||
<span aria-live="polite" className="filter-panel-count">
|
||||
{visibleCount} von {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? null : (
|
||||
<div className="filter-bar" id={barId}>
|
||||
{children}
|
||||
{isFilterActive ? (
|
||||
<IconButton
|
||||
className="filter-bar-reset"
|
||||
icon={FilterX}
|
||||
label="Zurücksetzen"
|
||||
onClick={onReset}
|
||||
showLabel
|
||||
title="Alle Filter zurücksetzen"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Search field for a filter bar. */
|
||||
export function FilterSearchField({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="filter-bar-field">
|
||||
<span className="filter-bar-label">{label}</span>
|
||||
<input
|
||||
className="filter-bar-search"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="search"
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||
|
|
|
|||
40
src/frontend/components/icon-link.tsx
Normal file
40
src/frontend/components/icon-link.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
||||
* look. `label` is always the accessible name and, unless `title` is given,
|
||||
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
||||
*/
|
||||
export function IconLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
title,
|
||||
href,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
title?: string;
|
||||
href: string;
|
||||
variant?: "default" | "danger" | "primary";
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
aria-label={showLabel ? undefined : label}
|
||||
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||
href={href}
|
||||
title={title ?? label}
|
||||
>
|
||||
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||
{showLabel ? <span>{label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
toggleFilterValue,
|
||||
type FilterOption,
|
||||
} from "../utils/overview-filters";
|
||||
|
||||
/**
|
||||
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
||||
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
||||
* click and Escape.
|
||||
*/
|
||||
export function MultiSelectDropdown({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyLabel = "Keine Einträge vorhanden",
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly FilterOption[];
|
||||
selected: readonly string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const panelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const summary =
|
||||
selected.length === 0
|
||||
? "Alle"
|
||||
: selected.length === 1
|
||||
? options.find((option) => option.id === selected[0])?.label ??
|
||||
"1 ausgewählt"
|
||||
: `${selected.length} ausgewählt`;
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={containerRef}>
|
||||
<span className="filter-bar-label" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
ref={toggleRef}
|
||||
title={summary}
|
||||
type="button"
|
||||
>
|
||||
<span className="filter-dropdown-value" id={valueId}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
aria-labelledby={labelId}
|
||||
className="filter-dropdown-panel"
|
||||
id={panelId}
|
||||
role="group"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="filter-dropdown-option filter-dropdown-option-all">
|
||||
<input
|
||||
checked={selected.length === 0}
|
||||
onChange={() => onChange([])}
|
||||
type="checkbox"
|
||||
/>
|
||||
Alle
|
||||
</label>
|
||||
{options.map((option) => (
|
||||
<label className="filter-dropdown-option" key={option.id}>
|
||||
<input
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={() =>
|
||||
onChange(toggleFilterValue(selected, option.id))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,14 @@ import {
|
|||
getProjectSnapshotRevisionDescription,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||
|
||||
interface ProjectVersionHistoryProps {
|
||||
projectId: string;
|
||||
|
|
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
|
|||
<span className="badge text-bg-secondary">
|
||||
Aktuelle Revision {currentRevision}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Wiederholen
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
</button>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
icon={Undo2}
|
||||
label="Rückgängig"
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
icon={Redo2}
|
||||
label="Wiederholen"
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
ariaExpanded={isOpen}
|
||||
icon={isOpen ? ChevronUp : ChevronDown}
|
||||
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
|
|||
</td>
|
||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
disabled={isBusy}
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
type="button"
|
||||
>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<div className="overview-actions justify-content-end">
|
||||
<IconButton
|
||||
disabled={isBusy}
|
||||
icon={RotateCcw}
|
||||
label="Wiederherstellen"
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue