Merge remote-tracking branch 'origin/main' into feature/cable-sizing
This commit is contained in:
commit
598003554a
136 changed files with 27182 additions and 1669 deletions
|
|
@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
|
||||
// Every page shows the (collapsible) sidebar except the print view, which is
|
||||
// a stand-alone document opened in its own tab and printed as PDF.
|
||||
const PRINT_VIEW_PATTERN = /^\/projects\/[^/]+\/print(\/|$)/;
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
|
||||
const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? "");
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -7,14 +7,22 @@ import { listProjects } from "../utils/api";
|
|||
import type { ProjectDto } from "../types";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
// Anchors on the project overview page (/projects/[id]).
|
||||
const PROJECT_SECTIONS = [
|
||||
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
||||
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
||||
{ id: "etagen", label: "Etagen", icon: "▥" },
|
||||
{ id: "raeume", label: "Räume", icon: "⌂" },
|
||||
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
||||
] as const;
|
||||
|
||||
// Separate project sub-routes (/projects/[id]/<segment>).
|
||||
const PROJECT_ROUTES = [
|
||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
||||
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
|
||||
] as const;
|
||||
|
||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
||||
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
|
|
@ -22,8 +30,14 @@ interface SidebarProps {
|
|||
|
||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||
const projectSubPath = projectMatch?.[2] ?? "";
|
||||
// Section anchors only exist (and are observed) on the overview itself.
|
||||
const overviewProjectId =
|
||||
activeProjectId && (projectSubPath === "" || projectSubPath === "/")
|
||||
? activeProjectId
|
||||
: null;
|
||||
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
|
|
@ -42,7 +56,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
if (!overviewProjectId) {
|
||||
setActiveSection(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -69,7 +83,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [activeProjectId]);
|
||||
}, [overviewProjectId]);
|
||||
|
||||
return (
|
||||
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
|
||||
|
|
@ -122,17 +136,46 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
{activeProjectId && (
|
||||
<>
|
||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
))}
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
|
||||
overviewProjectId ? (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/projects/${activeProjectId}#${id}`}
|
||||
className="sidebar-link"
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{PROJECT_ROUTES.map(({ segment, label, icon }) => {
|
||||
const href = `/projects/${activeProjectId}/${segment}`;
|
||||
const isActive =
|
||||
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
|
||||
return (
|
||||
<Link
|
||||
key={segment}
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={isActive ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Plus,
|
||||
Redo2,
|
||||
ShieldPlus,
|
||||
TableProperties,
|
||||
Trash2,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -100,6 +101,7 @@ import {
|
|||
type MutableDistributionBoardComponentRole,
|
||||
} from "../utils/distribution-board-component-editing";
|
||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
import type {
|
||||
CellKey,
|
||||
ColumnDef,
|
||||
|
|
@ -159,6 +161,7 @@ import type {
|
|||
CircuitSnapshot,
|
||||
} from "../../domain/models/circuit-structure-project-command.model";
|
||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||
import { GzfPickerModal } from "./gzf-picker-modal";
|
||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||
import { CableSizingModal } from "./cable-sizing-modal";
|
||||
|
|
@ -277,7 +280,7 @@ function createValuesFromEditPatch(
|
|||
* header heights stay uniform.
|
||||
*/
|
||||
function renderColumnHeaderLabel(label: string) {
|
||||
const match = /^(.*?)s*([[^]]+])$/.exec(label);
|
||||
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
|
||||
const name = (match ? match[1] : label).replace(/-/g, "‑");
|
||||
return match ? (
|
||||
<>
|
||||
|
|
@ -319,6 +322,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
useState<StructureComponentEditorIntent | null>(null);
|
||||
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
||||
useState<CircuitGroupEditorIntent | null>(null);
|
||||
// GZF cell the AMEV picker writes to; null while the picker is closed.
|
||||
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
|
||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||
useState<CircuitTreeCircuitDto | null>(null);
|
||||
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
||||
|
|
@ -1022,7 +1027,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
defaultSortOrder: number
|
||||
) {
|
||||
return buildCircuitDeviceRowInsertSnapshot({
|
||||
id: crypto.randomUUID(),
|
||||
id: createClientId(),
|
||||
circuitId,
|
||||
values,
|
||||
defaultSortOrder,
|
||||
|
|
@ -1046,7 +1051,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
),
|
||||
data
|
||||
);
|
||||
const circuitId = crypto.randomUUID();
|
||||
const circuitId = createClientId();
|
||||
const deviceRows = deviceRowValues.map((row, index) =>
|
||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||
);
|
||||
|
|
@ -1214,7 +1219,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
(section) => section.id === intent.sectionId
|
||||
)?.components ?? [];
|
||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||
id: crypto.randomUUID(),
|
||||
id: createClientId(),
|
||||
circuitListId,
|
||||
role: intent.role,
|
||||
sectionId: intent.sectionId,
|
||||
|
|
@ -1279,7 +1284,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
buildNewCircuitGroupSnapshot({
|
||||
id: crypto.randomUUID(),
|
||||
id: createClientId(),
|
||||
circuitListId,
|
||||
category: values.category,
|
||||
displayName: values.displayName,
|
||||
|
|
@ -1899,22 +1904,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
}
|
||||
|
||||
// Commits the active editor input through command history so edits remain undoable.
|
||||
// `target` commits a value for a cell without an open inline editor
|
||||
// (AMEV GZF picker); the regular edit path passes no target.
|
||||
async function commitEdit(
|
||||
direction: SaveDirection = "stay",
|
||||
draftOverride?: string
|
||||
draftOverride?: string,
|
||||
target?: SelectedCell
|
||||
) {
|
||||
if (!editingCell) {
|
||||
const session = target ?? editingCell;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const row = findRow(editingCell.rowKey);
|
||||
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
||||
const row = findRow(session.rowKey);
|
||||
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
|
||||
if (!row || !cell || !cell.editable) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
const draft = draftOverride ?? editingCell.draft;
|
||||
const draft = draftOverride ?? editingCell?.draft ?? "";
|
||||
if (
|
||||
editingCell.cellKey === "equipmentIdentifier" &&
|
||||
session.cellKey === "equipmentIdentifier" &&
|
||||
row.circuit &&
|
||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||
) {
|
||||
|
|
@ -1922,7 +1931,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
return;
|
||||
}
|
||||
|
||||
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
||||
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
|
||||
const nextSelectionIntent = () => {
|
||||
let selected = targetSelection;
|
||||
if (direction !== "stay") {
|
||||
|
|
@ -1945,7 +1954,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
redo: async () => {
|
||||
const selection = await createFromPlaceholder(
|
||||
row.sectionId,
|
||||
editingCell.cellKey,
|
||||
session.cellKey,
|
||||
draft
|
||||
);
|
||||
targetSelection = selection;
|
||||
|
|
@ -1962,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const command: HistoryCommand = {
|
||||
label: "Stromkreisfeld bearbeiten",
|
||||
redo: async () => {
|
||||
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
||||
await patchCircuit(row.circuit!.id, session.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -1976,7 +1985,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const command: HistoryCommand = {
|
||||
label: "Gerätefeld bearbeiten",
|
||||
redo: async () => {
|
||||
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
||||
await patchDeviceRow(row.device!.id, session.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -1991,7 +2000,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||
redo: async () => {
|
||||
const editValues = createValuesFromEditPatch(
|
||||
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
||||
buildDeviceRowEditPatch(session.cellKey, next)
|
||||
);
|
||||
const snapshot = createDeviceRowSnapshot(
|
||||
row.circuit!.id,
|
||||
|
|
@ -2011,7 +2020,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
snapshot,
|
||||
"Gerätezeile im Reservestromkreis erstellen"
|
||||
);
|
||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
};
|
||||
|
|
@ -2020,6 +2029,45 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
}
|
||||
}
|
||||
|
||||
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
|
||||
return Boolean(
|
||||
target &&
|
||||
target.cellKey === "simultaneityFactor" &&
|
||||
findCell(target.rowKey, target.cellKey)?.editable
|
||||
);
|
||||
}
|
||||
|
||||
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
|
||||
// inline edit is discarded because the picked value replaces it.
|
||||
function openGzfPicker(target: SelectedCell) {
|
||||
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
|
||||
return;
|
||||
}
|
||||
setEditingCell(null);
|
||||
setSelectedCell(target);
|
||||
setGzfPickerCell(target);
|
||||
}
|
||||
|
||||
function closeGzfPicker() {
|
||||
setGzfPickerCell(null);
|
||||
focusGridWithoutScroll();
|
||||
}
|
||||
|
||||
// Commits the picked mean value like a typed edit (undoable command).
|
||||
function handleGzfPicked(value: number) {
|
||||
const target = gzfPickerCell;
|
||||
setGzfPickerCell(null);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
// parseNumeric accepts only the German decimal comma.
|
||||
const draft = value.toLocaleString("de-DE", {
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
void commitEdit("stay", draft, target);
|
||||
}
|
||||
|
||||
// Cancels current edit session and restores grid keyboard focus.
|
||||
function cancelEdit() {
|
||||
if (!editingCell) {
|
||||
|
|
@ -2963,6 +3011,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
// - printable key starts overwrite edit (type-to-edit)
|
||||
// - Arrow/Tab move selectedCell when not editing
|
||||
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
// The picker modal sits outside the grid; keep grid keys from acting behind it.
|
||||
if (gzfPickerCell) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeGzfPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editingCell) {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
|
|
@ -3030,6 +3086,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
moveHorizontal(-1);
|
||||
return;
|
||||
}
|
||||
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
|
||||
event.preventDefault();
|
||||
openGzfPicker(selectedCell);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveVertical(1);
|
||||
|
|
@ -3197,6 +3258,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
onSave={handleSaveCircuitGroup}
|
||||
/>
|
||||
) : null}
|
||||
{gzfPickerCell ? (
|
||||
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
|
||||
) : null}
|
||||
{protectionEditorCircuit ? (
|
||||
<CircuitProtectionModal
|
||||
equipmentIdentifier={
|
||||
|
|
@ -4530,11 +4594,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
void commitEdit(event.shiftKey ? "prev" : "next");
|
||||
} else if (
|
||||
event.altKey &&
|
||||
event.key === "ArrowDown" &&
|
||||
column.key === "simultaneityFactor"
|
||||
) {
|
||||
event.preventDefault();
|
||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
|
||||
return;
|
||||
}
|
||||
if (!active || !containerRef.current?.contains(active)) {
|
||||
setEditingCell(null);
|
||||
focusGridWithoutScroll();
|
||||
|
|
@ -4542,6 +4616,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
});
|
||||
}}
|
||||
/>
|
||||
) : column.key === "simultaneityFactor" &&
|
||||
cell.editable &&
|
||||
isSelected ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
className="cell-picker-trigger"
|
||||
disabled={isSaving || historyBusy}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
||||
}}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
|
||||
type="button"
|
||||
>
|
||||
<TableProperties aria-hidden="true" size={13} />
|
||||
</button>
|
||||
{formatCellValue(cell.value, column.key)}
|
||||
</>
|
||||
) : (
|
||||
formatCellValue(cell.value, column.key)
|
||||
)}
|
||||
|
|
|
|||
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
distributionBoardInfoFieldLabels,
|
||||
distributionBoardInfoFieldMaxLengths,
|
||||
distributionBoardInfoFields,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import type { DistributionBoardInfoDto } from "../types";
|
||||
|
||||
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
|
||||
|
||||
const placeholders: Record<DistributionBoardInfoField, string> = {
|
||||
boardNumber: "z. B. UV 1.1",
|
||||
location: "z. B. Raum 003 Technik",
|
||||
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
export function toDistributionBoardInfoDraft(
|
||||
info: DistributionBoardInfoDto
|
||||
): DistributionBoardInfoDraft {
|
||||
return {
|
||||
boardNumber: info.boardNumber ?? "",
|
||||
location: info.location ?? "",
|
||||
fedFrom: info.fedFrom ?? "",
|
||||
remark: info.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function fromDistributionBoardInfoDraft(
|
||||
draft: DistributionBoardInfoDraft
|
||||
): DistributionBoardInfoDto {
|
||||
const value = (field: DistributionBoardInfoField) =>
|
||||
draft[field].trim() ? draft[field].trim() : null;
|
||||
return {
|
||||
boardNumber: value("boardNumber"),
|
||||
location: value("location"),
|
||||
fedFrom: value("fedFrom"),
|
||||
remark: value("remark"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptive board data used by the print view title block. The values are
|
||||
* local planning values; a later Revit import may prefill them only through
|
||||
* an explicit, previewed decision.
|
||||
*/
|
||||
export function DistributionBoardInfoCard({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: DistributionBoardInfoDraft;
|
||||
onChange: (draft: DistributionBoardInfoDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="distribution-board-info-heading"
|
||||
className="card"
|
||||
>
|
||||
<div className="card-header" id="distribution-board-info-heading">
|
||||
Infokarte
|
||||
</div>
|
||||
<div className="card-body row g-3">
|
||||
{distributionBoardInfoFields.map((field) => (
|
||||
<div
|
||||
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
|
||||
key={field}
|
||||
>
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`distribution-board-info-${field}`}
|
||||
>
|
||||
{distributionBoardInfoFieldLabels[field]}
|
||||
</label>
|
||||
{field === "remark" ? (
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={`distribution-board-info-${field}`}
|
||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, [field]: event.target.value })
|
||||
}
|
||||
rows={2}
|
||||
value={draft[field]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control"
|
||||
id={`distribution-board-info-${field}`}
|
||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, [field]: event.target.value })
|
||||
}
|
||||
placeholder={placeholders[field]}
|
||||
value={draft[field]}
|
||||
/>
|
||||
)}
|
||||
{field === "fedFrom" ? (
|
||||
<div className="form-text">
|
||||
Vorerst Freitext. Die logische Verknüpfung mit dem
|
||||
speisenden Verteiler folgt.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
49
src/frontend/components/floor-select-options.tsx
Normal file
49
src/frontend/components/floor-select-options.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { BuildingDto, FloorDto } from "../types";
|
||||
import { groupFloorsByBuilding } from "../utils/building-structure";
|
||||
|
||||
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
|
||||
interface FloorSelectOptionsProps {
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
|
||||
* as the project has buildings. The caller renders its own empty option
|
||||
* ("Ohne Etage") before these.
|
||||
*/
|
||||
export function FloorSelectOptions({
|
||||
buildings = [],
|
||||
floors,
|
||||
}: FloorSelectOptionsProps) {
|
||||
if (!buildings.length) {
|
||||
return (
|
||||
<>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groupFloorsByBuilding(floors, buildings).map((group) => (
|
||||
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
|
||||
{group.floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
||||
|
||||
interface GzfPickerModalProps {
|
||||
|
|
@ -12,14 +13,50 @@ function formatFactor(value: number) {
|
|||
}
|
||||
|
||||
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the inline editor's blur callback before taking keyboard focus.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
|
||||
<div
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
} else if (event.key === "Tab") {
|
||||
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
|
||||
const first = buttons?.[0];
|
||||
const last = buttons?.[buttons.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||
<p className="text-secondary small mb-0">
|
||||
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,16 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import {
|
||||
projectCoverFieldLabels,
|
||||
projectCoverFieldMaxLengths,
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
export interface ProjectSettingsInput {
|
||||
export interface ProjectSettingsInput
|
||||
extends Record<ProjectCoverField, string | null> {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
|
|
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
|
|||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
|
||||
siteAddress: "Straße Nr.\nPLZ Ort",
|
||||
workStage: "z. B. LPH 5 – Ausführungsplanung",
|
||||
projectLead: "",
|
||||
projectEditor: "",
|
||||
};
|
||||
|
||||
interface ProjectSettingsModalProps {
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
|
|||
] = useState<DistributionBoardSupplyType[]>(
|
||||
project.enabledDistributionBoardSupplyTypes
|
||||
);
|
||||
const [coverValues, setCoverValues] = useState<
|
||||
Record<ProjectCoverField, string>
|
||||
>({
|
||||
siteAddress: project.siteAddress ?? "",
|
||||
workStage: project.workStage ?? "",
|
||||
projectLead: project.projectLead ?? "",
|
||||
projectEditor: project.projectEditor ?? "",
|
||||
});
|
||||
const [transfer, setTransfer] = useState<unknown>(null);
|
||||
const [transferFilename, setTransferFilename] = useState("");
|
||||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||
|
|
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
|
|||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: toNullableString(coverValues.siteAddress),
|
||||
workStage: toNullableString(coverValues.workStage),
|
||||
projectLead: toNullableString(coverValues.projectLead),
|
||||
projectEditor: toNullableString(coverValues.projectEditor),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
|
|||
value={threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Deckblatt</h3>
|
||||
<p className="text-secondary small">
|
||||
Angaben für das Deckblatt der Seitenansicht. Büroname und
|
||||
Büroanschrift werden projektübergreifend in der Seitenansicht
|
||||
gepflegt.
|
||||
</p>
|
||||
<div className="row g-3">
|
||||
{projectCoverFields.map((field) => (
|
||||
<div
|
||||
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
|
||||
key={field}
|
||||
>
|
||||
<label className="form-label" htmlFor={`project-cover-${field}`}>
|
||||
{projectCoverFieldLabels[field]}
|
||||
</label>
|
||||
{field === "siteAddress" ? (
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
rows={2}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { type FormEvent, useState } from "react";
|
||||
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
|
||||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface RoomModalProps {
|
||||
floors: FloorDto[];
|
||||
/** When given, the floor select groups floors by building. */
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
initialRoom?: RoomDto;
|
||||
initialValues?: Partial<CreateRoomInput>;
|
||||
isSaving: boolean;
|
||||
|
|
@ -15,6 +18,7 @@ interface RoomModalProps {
|
|||
}
|
||||
|
||||
export function RoomModal({
|
||||
buildings,
|
||||
floors,
|
||||
initialRoom,
|
||||
initialValues,
|
||||
|
|
@ -83,7 +87,7 @@ export function RoomModal({
|
|||
value={floorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
|
||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue