1666 lines
74 KiB
TypeScript
1666 lines
74 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useParams, useSearchParams } from "next/navigation";
|
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
createConsumer,
|
|
deleteConsumer,
|
|
getProject,
|
|
listConsumers,
|
|
listDistributionBoards,
|
|
listProjectDevices,
|
|
listRooms,
|
|
updateConsumer,
|
|
} from "../../../../frontend/utils/api";
|
|
import type {
|
|
ConsumerWithCalculatedValues,
|
|
CreateConsumerInput,
|
|
DistributionBoardDto,
|
|
ProjectDeviceDto,
|
|
ProjectDto,
|
|
RoomDto,
|
|
} from "../../../../frontend/types";
|
|
import {
|
|
cableCrossSectionOptions,
|
|
cableTypeOptions,
|
|
consumerGroupOptions,
|
|
deviceTypeOptions,
|
|
phaseTypeOptions,
|
|
protectionCharacteristicOptions,
|
|
protectionTypeOptions,
|
|
tradeOrCostGroupOptions,
|
|
} from "../../../../shared/constants/consumer-option-lists";
|
|
|
|
interface SlotState {
|
|
boardId: string;
|
|
selectedConsumerIds: string[];
|
|
}
|
|
|
|
interface QuickCreateFormState {
|
|
name: string;
|
|
roomId: string;
|
|
quantity: string;
|
|
installedPowerPerUnitKw: string;
|
|
demandFactor: string;
|
|
totalPowerKw: string;
|
|
addCount: string;
|
|
}
|
|
|
|
type SortField = "name" | "circuitNumber" | "quantity" | "installedPowerPerUnitKw" | "demandFactor" | "demandPowerKw";
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
interface ListFilterState {
|
|
query: string;
|
|
sortField: SortField;
|
|
sortDirection: SortDirection;
|
|
}
|
|
|
|
interface BulkEditState {
|
|
quantity: string;
|
|
installedPowerPerUnitKw: string;
|
|
demandFactor: string;
|
|
}
|
|
|
|
type ColumnKey =
|
|
| "selection"
|
|
| "circuitNumber"
|
|
| "description"
|
|
| "name"
|
|
| "projectDevice"
|
|
| "deviceLink"
|
|
| "room"
|
|
| "floor"
|
|
| "category"
|
|
| "deviceType"
|
|
| "phaseType"
|
|
| "tradeOrCostGroup"
|
|
| "group"
|
|
| "protectionType"
|
|
| "protectionRatedCurrent"
|
|
| "protectionCharacteristic"
|
|
| "cableType"
|
|
| "cableCrossSection"
|
|
| "comment"
|
|
| "quantity"
|
|
| "installedPowerPerUnitKw"
|
|
| "installedPowerKw"
|
|
| "demandFactor"
|
|
| "demandPowerKw"
|
|
| "voltageV"
|
|
| "phaseCount"
|
|
| "powerFactor"
|
|
| "currentA"
|
|
| "note"
|
|
| "actions";
|
|
|
|
const defaultSlots: SlotState[] = [
|
|
{ boardId: "", selectedConsumerIds: [] },
|
|
{ boardId: "", selectedConsumerIds: [] },
|
|
{ boardId: "", selectedConsumerIds: [] },
|
|
];
|
|
|
|
const defaultCreateForm: QuickCreateFormState = {
|
|
name: "",
|
|
roomId: "",
|
|
quantity: "1",
|
|
installedPowerPerUnitKw: "0.10",
|
|
demandFactor: "1.00",
|
|
totalPowerKw: "0.10",
|
|
addCount: "1",
|
|
};
|
|
|
|
const defaultFilterState: ListFilterState = {
|
|
query: "",
|
|
sortField: "name",
|
|
sortDirection: "asc",
|
|
};
|
|
|
|
const defaultBulkEditState: BulkEditState = {
|
|
quantity: "",
|
|
installedPowerPerUnitKw: "",
|
|
demandFactor: "",
|
|
};
|
|
|
|
const allColumns: Array<{ key: ColumnKey; label: string }> = [
|
|
{ key: "selection", label: "Auswahl" },
|
|
{ key: "circuitNumber", label: "Stromkreis-Nr." },
|
|
{ key: "description", label: "Beschreibung" },
|
|
{ key: "name", label: "Verbraucher" },
|
|
{ key: "projectDevice", label: "Projektgerät" },
|
|
{ key: "deviceLink", label: "Verknüpfung" },
|
|
{ key: "room", label: "Raum" },
|
|
{ key: "floor", label: "Etage" },
|
|
{ key: "category", label: "Kategorie" },
|
|
{ key: "deviceType", label: "Geräteart" },
|
|
{ key: "phaseType", label: "Phasenart" },
|
|
{ key: "tradeOrCostGroup", label: "Kostengruppe" },
|
|
{ key: "group", label: "Gruppe" },
|
|
{ key: "protectionType", label: "Schutzart" },
|
|
{ key: "protectionRatedCurrent", label: "Schutznennstrom [A]" },
|
|
{ key: "protectionCharacteristic", label: "Schutzkennlinie" },
|
|
{ key: "cableType", label: "Kabeltyp" },
|
|
{ key: "cableCrossSection", label: "Querschnitt" },
|
|
{ key: "comment", label: "Kommentar" },
|
|
{ key: "quantity", label: "Anzahl" },
|
|
{ key: "installedPowerPerUnitKw", label: "Einzelleistung [kW]" },
|
|
{ key: "installedPowerKw", label: "Installierte Leistung [kW]" },
|
|
{ key: "demandFactor", label: "Gleichzeitigkeitsfaktor" },
|
|
{ key: "demandPowerKw", label: "Gesamtleistung [kW]" },
|
|
{ key: "voltageV", label: "Spannung [V]" },
|
|
{ key: "phaseCount", label: "Phasen" },
|
|
{ key: "powerFactor", label: "cos f" },
|
|
{ key: "currentA", label: "Strom [A]" },
|
|
{ key: "note", label: "Bemerkung" },
|
|
{ key: "actions", label: "Aktionen" },
|
|
];
|
|
|
|
const defaultVisibleColumns: ColumnKey[] = [
|
|
"selection",
|
|
"circuitNumber",
|
|
"description",
|
|
"name",
|
|
"projectDevice",
|
|
"deviceLink",
|
|
"room",
|
|
"quantity",
|
|
"installedPowerPerUnitKw",
|
|
"demandFactor",
|
|
"demandPowerKw",
|
|
"actions",
|
|
];
|
|
|
|
function formatNumber(value: number | undefined, digits = 2) {
|
|
if (value === undefined || Number.isNaN(value)) {
|
|
return "-";
|
|
}
|
|
return value.toFixed(digits);
|
|
}
|
|
|
|
function roomLabel(room: RoomDto) {
|
|
return `${room.roomNumber} - ${room.roomName}`;
|
|
}
|
|
|
|
export default function CircuitListsPage() {
|
|
const params = useParams<{ projectId: string }>();
|
|
const searchParams = useSearchParams();
|
|
const [projectId, setProjectId] = useState("");
|
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
|
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
|
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
|
const [consumers, setConsumers] = useState<ConsumerWithCalculatedValues[]>([]);
|
|
const [slots, setSlots] = useState<SlotState[]>(defaultSlots);
|
|
const [activeListCount, setActiveListCount] = useState(1);
|
|
const [newDeviceTemplateId, setNewDeviceTemplateId] = useState<Record<number, string>>({
|
|
0: "",
|
|
1: "",
|
|
2: "",
|
|
});
|
|
const [quickCreateForms, setQuickCreateForms] = useState<Record<number, QuickCreateFormState>>({
|
|
0: { ...defaultCreateForm },
|
|
1: { ...defaultCreateForm },
|
|
2: { ...defaultCreateForm },
|
|
});
|
|
const [visibleColumns, setVisibleColumns] = useState<ColumnKey[]>(defaultVisibleColumns);
|
|
const [listFilters, setListFilters] = useState<Record<number, ListFilterState>>({
|
|
0: { ...defaultFilterState },
|
|
1: { ...defaultFilterState },
|
|
2: { ...defaultFilterState },
|
|
});
|
|
const [bulkEditForms, setBulkEditForms] = useState<Record<number, BulkEditState>>({
|
|
0: { ...defaultBulkEditState },
|
|
1: { ...defaultBulkEditState },
|
|
2: { ...defaultBulkEditState },
|
|
});
|
|
const [showColumnManager, setShowColumnManager] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setProjectId(params.projectId);
|
|
}, [params.projectId]);
|
|
|
|
useEffect(() => {
|
|
if (!projectId) {
|
|
return;
|
|
}
|
|
Promise.all([
|
|
getProject(projectId),
|
|
listDistributionBoards(projectId),
|
|
listRooms(projectId),
|
|
listProjectDevices(projectId),
|
|
listConsumers(projectId),
|
|
])
|
|
.then(([loadedProject, distributionBoards, loadedRooms, loadedProjectDevices, loadedConsumers]) => {
|
|
const initialBoardId = searchParams.get("boardId") ?? distributionBoards[0]?.id ?? "";
|
|
setProject(loadedProject);
|
|
setBoards(distributionBoards);
|
|
setRooms(loadedRooms);
|
|
setProjectDevices(loadedProjectDevices);
|
|
setConsumers(loadedConsumers);
|
|
setSlots((current) => [
|
|
{ ...current[0], boardId: current[0].boardId || initialBoardId },
|
|
current[1],
|
|
current[2],
|
|
]);
|
|
setActiveListCount(1);
|
|
setError(null);
|
|
})
|
|
.catch((err: unknown) =>
|
|
setError(err instanceof Error ? err.message : "Stromkreislisten konnten nicht geladen werden.")
|
|
);
|
|
}, [projectId, searchParams]);
|
|
|
|
const boardById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
|
const slotColumnClass =
|
|
activeListCount === 1 ? "col-12" : activeListCount === 2 ? "col-12 col-xxl-6" : "col-12 col-xxl-4";
|
|
|
|
function consumersForBoard(boardId: string) {
|
|
return consumers.filter((item) => item.distributionBoardId === boardId);
|
|
}
|
|
|
|
function updateListFilter(slotIndex: number, patch: Partial<ListFilterState>) {
|
|
setListFilters((current) => ({
|
|
...current,
|
|
[slotIndex]: { ...current[slotIndex], ...patch },
|
|
}));
|
|
}
|
|
|
|
function updateBulkEditForm(slotIndex: number, patch: Partial<BulkEditState>) {
|
|
setBulkEditForms((current) => ({
|
|
...current,
|
|
[slotIndex]: { ...current[slotIndex], ...patch },
|
|
}));
|
|
}
|
|
|
|
function getComparableSortValue(consumer: ConsumerWithCalculatedValues, sortField: SortField): number | string {
|
|
switch (sortField) {
|
|
case "name":
|
|
return (consumer.name ?? "").toLocaleLowerCase("de-DE");
|
|
case "circuitNumber":
|
|
return (consumer.circuitNumber ?? "").toLocaleLowerCase("de-DE");
|
|
case "quantity":
|
|
return consumer.quantity ?? 0;
|
|
case "installedPowerPerUnitKw":
|
|
return consumer.installedPowerPerUnitKw ?? 0;
|
|
case "demandFactor":
|
|
return consumer.demandFactor ?? 0;
|
|
case "demandPowerKw":
|
|
return consumer.demandPowerKw ?? 0;
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function setSlotBoard(slotIndex: number, boardId: string) {
|
|
setSlots((current) =>
|
|
current.map((slot, index) => (index === slotIndex ? { boardId, selectedConsumerIds: [] } : slot))
|
|
);
|
|
}
|
|
|
|
function addList() {
|
|
setActiveListCount((current) => Math.min(3, current + 1));
|
|
}
|
|
|
|
function removeList() {
|
|
setActiveListCount((current) => {
|
|
const next = Math.max(1, current - 1);
|
|
setSlots((slotState) =>
|
|
slotState.map((slot, index) => (index >= next ? { boardId: "", selectedConsumerIds: [] } : slot))
|
|
);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleConsumerSelection(slotIndex: number, consumerId: string) {
|
|
setSlots((current) =>
|
|
current.map((slot, index) => {
|
|
if (index !== slotIndex) {
|
|
return slot;
|
|
}
|
|
const exists = slot.selectedConsumerIds.includes(consumerId);
|
|
return {
|
|
...slot,
|
|
selectedConsumerIds: exists
|
|
? slot.selectedConsumerIds.filter((id) => id !== consumerId)
|
|
: [...slot.selectedConsumerIds, consumerId],
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
function updateQuickCreateForm(slotIndex: number, patch: Partial<QuickCreateFormState>) {
|
|
setQuickCreateForms((current) => {
|
|
const next = { ...current[slotIndex], ...patch };
|
|
const quantity = Number(next.quantity);
|
|
const unit = Number(next.installedPowerPerUnitKw);
|
|
const demandFactor = Number(next.demandFactor);
|
|
if (!Number.isNaN(quantity) && !Number.isNaN(unit) && !Number.isNaN(demandFactor)) {
|
|
next.totalPowerKw = String(quantity * unit * demandFactor);
|
|
}
|
|
return { ...current, [slotIndex]: next };
|
|
});
|
|
}
|
|
|
|
function updateTotalPower(slotIndex: number, totalPowerKw: string) {
|
|
setQuickCreateForms((current) => {
|
|
const next = { ...current[slotIndex], totalPowerKw };
|
|
const quantity = Number(next.quantity);
|
|
const demandFactor = Number(next.demandFactor);
|
|
const total = Number(totalPowerKw);
|
|
if (
|
|
!Number.isNaN(quantity) &&
|
|
!Number.isNaN(demandFactor) &&
|
|
!Number.isNaN(total) &&
|
|
quantity > 0 &&
|
|
demandFactor > 0
|
|
) {
|
|
next.installedPowerPerUnitKw = String(total / (quantity * demandFactor));
|
|
}
|
|
return { ...current, [slotIndex]: next };
|
|
});
|
|
}
|
|
|
|
async function reloadConsumers() {
|
|
if (!projectId) {
|
|
return;
|
|
}
|
|
setConsumers(await listConsumers(projectId));
|
|
}
|
|
|
|
async function handleCreateManualConsumer(slotIndex: number, event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const slot = slots[slotIndex];
|
|
const form = quickCreateForms[slotIndex];
|
|
const name = form.name.trim();
|
|
if (!projectId || !slot.boardId || !name) {
|
|
return;
|
|
}
|
|
|
|
const payload: CreateConsumerInput = {
|
|
projectId,
|
|
distributionBoardId: slot.boardId,
|
|
isLinkedToDevice: false,
|
|
roomId: form.roomId || undefined,
|
|
description: name,
|
|
name,
|
|
quantity: Number(form.quantity),
|
|
installedPowerPerUnitKw: Number(form.installedPowerPerUnitKw),
|
|
demandFactor: Number(form.demandFactor),
|
|
phaseCount: 1,
|
|
powerFactor: 1,
|
|
};
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await createConsumer(payload);
|
|
setQuickCreateForms((current) => ({
|
|
...current,
|
|
[slotIndex]: { ...defaultCreateForm, roomId: current[slotIndex].roomId },
|
|
}));
|
|
await reloadConsumers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht erstellt werden.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateFromProjectDevice(slotIndex: number) {
|
|
const slot = slots[slotIndex];
|
|
const form = quickCreateForms[slotIndex];
|
|
const templateId = newDeviceTemplateId[slotIndex];
|
|
const projectDevice = projectDevices.find((item) => item.id === templateId);
|
|
if (!projectId || !slot.boardId || !projectDevice) {
|
|
return;
|
|
}
|
|
const addCountRaw = Number(form.addCount);
|
|
const addCount = Number.isFinite(addCountRaw) ? Math.max(1, Math.floor(addCountRaw)) : 1;
|
|
|
|
const payload: CreateConsumerInput = {
|
|
projectId,
|
|
distributionBoardId: slot.boardId,
|
|
projectDeviceId: projectDevice.id,
|
|
isLinkedToDevice: true,
|
|
roomId: form.roomId || undefined,
|
|
description: projectDevice.displayName,
|
|
name: projectDevice.displayName,
|
|
category: projectDevice.category ?? undefined,
|
|
quantity: projectDevice.quantity,
|
|
installedPowerPerUnitKw: projectDevice.installedPowerPerUnitKw,
|
|
demandFactor: projectDevice.demandFactor,
|
|
phaseCount: projectDevice.phaseCount ?? 1,
|
|
powerFactor: projectDevice.powerFactor ?? 1,
|
|
note: projectDevice.note ?? undefined,
|
|
};
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await Promise.all(Array.from({ length: addCount }, () => createConsumer(payload)));
|
|
await reloadConsumers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht aus Projektgerät erstellt werden.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCopyToSlot(sourceConsumer: ConsumerWithCalculatedValues, targetSlotIndex: number) {
|
|
const targetBoardId = slots[targetSlotIndex].boardId;
|
|
if (!projectId || !targetBoardId) {
|
|
return;
|
|
}
|
|
|
|
const payload: CreateConsumerInput = {
|
|
projectId,
|
|
distributionBoardId: targetBoardId,
|
|
projectDeviceId: sourceConsumer.projectDeviceId ?? undefined,
|
|
isLinkedToDevice: sourceConsumer.isLinkedToDevice,
|
|
circuitNumber: sourceConsumer.circuitNumber,
|
|
description: sourceConsumer.description,
|
|
roomId: sourceConsumer.roomId ?? undefined,
|
|
name: sourceConsumer.name,
|
|
category: sourceConsumer.category,
|
|
deviceType: sourceConsumer.deviceType,
|
|
phaseType: sourceConsumer.phaseType,
|
|
tradeOrCostGroup: sourceConsumer.tradeOrCostGroup,
|
|
group: sourceConsumer.group,
|
|
protectionType: sourceConsumer.protectionType,
|
|
protectionRatedCurrent: sourceConsumer.protectionRatedCurrent,
|
|
protectionCharacteristic: sourceConsumer.protectionCharacteristic,
|
|
cableType: sourceConsumer.cableType,
|
|
cableCrossSection: sourceConsumer.cableCrossSection,
|
|
comment: sourceConsumer.comment,
|
|
quantity: sourceConsumer.quantity,
|
|
installedPowerPerUnitKw: sourceConsumer.installedPowerPerUnitKw,
|
|
demandFactor: sourceConsumer.demandFactor,
|
|
voltageV: sourceConsumer.voltageV,
|
|
phaseCount: sourceConsumer.phaseCount,
|
|
powerFactor: sourceConsumer.powerFactor,
|
|
note: sourceConsumer.note,
|
|
};
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await createConsumer(payload);
|
|
await reloadConsumers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Kopieren in Ziel-Stromkreisliste fehlgeschlagen.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDuplicateInSameList(sourceConsumer: ConsumerWithCalculatedValues) {
|
|
if (!projectId || !sourceConsumer.distributionBoardId) {
|
|
return;
|
|
}
|
|
|
|
const payload: CreateConsumerInput = {
|
|
projectId,
|
|
distributionBoardId: sourceConsumer.distributionBoardId,
|
|
projectDeviceId: sourceConsumer.projectDeviceId ?? undefined,
|
|
isLinkedToDevice: sourceConsumer.isLinkedToDevice,
|
|
circuitNumber: sourceConsumer.circuitNumber,
|
|
description: sourceConsumer.description,
|
|
roomId: sourceConsumer.roomId ?? undefined,
|
|
name: sourceConsumer.name,
|
|
category: sourceConsumer.category,
|
|
deviceType: sourceConsumer.deviceType,
|
|
phaseType: sourceConsumer.phaseType,
|
|
tradeOrCostGroup: sourceConsumer.tradeOrCostGroup,
|
|
group: sourceConsumer.group,
|
|
protectionType: sourceConsumer.protectionType,
|
|
protectionRatedCurrent: sourceConsumer.protectionRatedCurrent,
|
|
protectionCharacteristic: sourceConsumer.protectionCharacteristic,
|
|
cableType: sourceConsumer.cableType,
|
|
cableCrossSection: sourceConsumer.cableCrossSection,
|
|
comment: sourceConsumer.comment,
|
|
quantity: sourceConsumer.quantity,
|
|
installedPowerPerUnitKw: sourceConsumer.installedPowerPerUnitKw,
|
|
demandFactor: sourceConsumer.demandFactor,
|
|
voltageV: sourceConsumer.voltageV,
|
|
phaseCount: sourceConsumer.phaseCount,
|
|
powerFactor: sourceConsumer.powerFactor,
|
|
note: sourceConsumer.note,
|
|
};
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await createConsumer(payload);
|
|
await reloadConsumers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Duplizieren in derselben Liste fehlgeschlagen.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCopySelectionToSlot(sourceSlotIndex: number, targetSlotIndex: number) {
|
|
const sourceSlot = slots[sourceSlotIndex];
|
|
const targetBoardId = slots[targetSlotIndex].boardId;
|
|
if (!projectId || !targetBoardId || !sourceSlot.selectedConsumerIds.length) {
|
|
return;
|
|
}
|
|
|
|
const selected = consumers.filter((consumer) => sourceSlot.selectedConsumerIds.includes(consumer.id));
|
|
if (!selected.length) {
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await Promise.all(
|
|
selected.map((sourceConsumer) =>
|
|
createConsumer({
|
|
projectId,
|
|
distributionBoardId: targetBoardId,
|
|
projectDeviceId: sourceConsumer.projectDeviceId ?? undefined,
|
|
isLinkedToDevice: sourceConsumer.isLinkedToDevice,
|
|
circuitNumber: sourceConsumer.circuitNumber,
|
|
description: sourceConsumer.description,
|
|
roomId: sourceConsumer.roomId ?? undefined,
|
|
name: sourceConsumer.name,
|
|
category: sourceConsumer.category,
|
|
deviceType: sourceConsumer.deviceType,
|
|
phaseType: sourceConsumer.phaseType,
|
|
tradeOrCostGroup: sourceConsumer.tradeOrCostGroup,
|
|
group: sourceConsumer.group,
|
|
protectionType: sourceConsumer.protectionType,
|
|
protectionRatedCurrent: sourceConsumer.protectionRatedCurrent,
|
|
protectionCharacteristic: sourceConsumer.protectionCharacteristic,
|
|
cableType: sourceConsumer.cableType,
|
|
cableCrossSection: sourceConsumer.cableCrossSection,
|
|
comment: sourceConsumer.comment,
|
|
quantity: sourceConsumer.quantity,
|
|
installedPowerPerUnitKw: sourceConsumer.installedPowerPerUnitKw,
|
|
demandFactor: sourceConsumer.demandFactor,
|
|
voltageV: sourceConsumer.voltageV,
|
|
phaseCount: sourceConsumer.phaseCount,
|
|
powerFactor: sourceConsumer.powerFactor,
|
|
note: sourceConsumer.note,
|
|
})
|
|
)
|
|
);
|
|
await reloadConsumers();
|
|
setSlots((current) =>
|
|
current.map((slot, index) => (index === sourceSlotIndex ? { ...slot, selectedConsumerIds: [] } : slot))
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Sammel-Kopieren fehlgeschlagen.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleBulkEditSelection(slotIndex: number) {
|
|
const slot = slots[slotIndex];
|
|
if (!slot.selectedConsumerIds.length) {
|
|
return;
|
|
}
|
|
|
|
const form = bulkEditForms[slotIndex];
|
|
const patch: Partial<CreateConsumerInput> = {};
|
|
const quantity = form.quantity.trim();
|
|
const installedPowerPerUnitKw = form.installedPowerPerUnitKw.trim();
|
|
const demandFactor = form.demandFactor.trim();
|
|
|
|
if (quantity) {
|
|
const value = Number(quantity);
|
|
if (!Number.isNaN(value) && value >= 0) {
|
|
patch.quantity = value;
|
|
}
|
|
}
|
|
if (installedPowerPerUnitKw) {
|
|
const value = Number(installedPowerPerUnitKw);
|
|
if (!Number.isNaN(value) && value >= 0) {
|
|
patch.installedPowerPerUnitKw = value;
|
|
}
|
|
}
|
|
if (demandFactor) {
|
|
const value = Number(demandFactor);
|
|
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
|
patch.demandFactor = value;
|
|
}
|
|
}
|
|
|
|
if (!Object.keys(patch).length) {
|
|
return;
|
|
}
|
|
|
|
const selected = consumers.filter((consumer) => slot.selectedConsumerIds.includes(consumer.id));
|
|
if (!selected.length) {
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await Promise.all(selected.map((consumer) => handleInlineUpdateFields(consumer, patch)));
|
|
await reloadConsumers();
|
|
setBulkEditForms((current) => ({ ...current, [slotIndex]: { ...defaultBulkEditState } }));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Sammeländerung fehlgeschlagen.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleInlineUpdateFields(
|
|
consumer: ConsumerWithCalculatedValues,
|
|
patch: Partial<CreateConsumerInput>
|
|
) {
|
|
const payload = {
|
|
projectId,
|
|
distributionBoardId:
|
|
patch.distributionBoardId !== undefined
|
|
? patch.distributionBoardId
|
|
: consumer.distributionBoardId ?? undefined,
|
|
circuitListId: patch.circuitListId !== undefined ? patch.circuitListId : consumer.circuitListId ?? undefined,
|
|
projectDeviceId:
|
|
patch.projectDeviceId !== undefined ? patch.projectDeviceId : consumer.projectDeviceId ?? undefined,
|
|
isLinkedToDevice:
|
|
patch.isLinkedToDevice !== undefined ? patch.isLinkedToDevice : consumer.isLinkedToDevice ?? false,
|
|
circuitNumber: patch.circuitNumber !== undefined ? patch.circuitNumber : consumer.circuitNumber,
|
|
description: patch.description !== undefined ? patch.description : consumer.description,
|
|
roomId: patch.roomId !== undefined ? patch.roomId : consumer.roomId ?? undefined,
|
|
name: patch.name ?? consumer.name,
|
|
category: patch.category !== undefined ? patch.category : consumer.category ?? undefined,
|
|
deviceType: patch.deviceType !== undefined ? patch.deviceType : consumer.deviceType,
|
|
phaseType: patch.phaseType !== undefined ? patch.phaseType : consumer.phaseType,
|
|
tradeOrCostGroup:
|
|
patch.tradeOrCostGroup !== undefined ? patch.tradeOrCostGroup : consumer.tradeOrCostGroup,
|
|
group: patch.group !== undefined ? patch.group : consumer.group,
|
|
protectionType: patch.protectionType !== undefined ? patch.protectionType : consumer.protectionType,
|
|
protectionRatedCurrent:
|
|
patch.protectionRatedCurrent !== undefined
|
|
? patch.protectionRatedCurrent
|
|
: consumer.protectionRatedCurrent,
|
|
protectionCharacteristic:
|
|
patch.protectionCharacteristic !== undefined
|
|
? patch.protectionCharacteristic
|
|
: consumer.protectionCharacteristic,
|
|
cableType: patch.cableType !== undefined ? patch.cableType : consumer.cableType,
|
|
cableCrossSection:
|
|
patch.cableCrossSection !== undefined ? patch.cableCrossSection : consumer.cableCrossSection,
|
|
comment: patch.comment !== undefined ? patch.comment : consumer.comment,
|
|
quantity: patch.quantity ?? consumer.quantity,
|
|
installedPowerPerUnitKw: patch.installedPowerPerUnitKw ?? consumer.installedPowerPerUnitKw,
|
|
demandFactor: patch.demandFactor ?? consumer.demandFactor,
|
|
voltageV: patch.voltageV !== undefined ? patch.voltageV : consumer.voltageV,
|
|
phaseCount: patch.phaseCount !== undefined ? patch.phaseCount : consumer.phaseCount,
|
|
powerFactor: patch.powerFactor !== undefined ? patch.powerFactor : consumer.powerFactor,
|
|
note: patch.note !== undefined ? patch.note : consumer.note,
|
|
};
|
|
try {
|
|
const updated = await updateConsumer(consumer.id, payload);
|
|
setConsumers((current) => current.map((item) => (item.id === consumer.id ? updated : item)));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Aktualisierung fehlgeschlagen.");
|
|
}
|
|
}
|
|
|
|
async function handleDeleteConsumer(consumerId: string) {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await deleteConsumer(consumerId);
|
|
await reloadConsumers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht gelöscht werden.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function toggleColumn(column: ColumnKey) {
|
|
if (column === "selection" || column === "actions") {
|
|
return;
|
|
}
|
|
setVisibleColumns((current) =>
|
|
current.includes(column) ? current.filter((item) => item !== column) : [...current, column]
|
|
);
|
|
}
|
|
|
|
function moveColumn(column: ColumnKey, direction: -1 | 1) {
|
|
setVisibleColumns((current) => {
|
|
const index = current.indexOf(column);
|
|
if (index < 0) {
|
|
return current;
|
|
}
|
|
const nextIndex = index + direction;
|
|
if (nextIndex < 0 || nextIndex >= current.length) {
|
|
return current;
|
|
}
|
|
const clone = [...current];
|
|
const [item] = clone.splice(index, 1);
|
|
clone.splice(nextIndex, 0, item);
|
|
return clone;
|
|
});
|
|
}
|
|
|
|
const renderedColumns = allColumns.filter((column) => visibleColumns.includes(column.key));
|
|
|
|
return (
|
|
<main className="container-fluid py-3">
|
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h1 className="h4 mb-1">Stromkreislisten</h1>
|
|
<p className="text-secondary mb-0">Projekt: {project?.name ?? projectId}</p>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={addList}
|
|
disabled={activeListCount >= 3}
|
|
>
|
|
Liste hinzufügen
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={removeList}
|
|
disabled={activeListCount <= 1}
|
|
>
|
|
Liste entfernen
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={() => setShowColumnManager((current) => !current)}
|
|
>
|
|
Spalten konfigurieren
|
|
</button>
|
|
<Link className="btn btn-outline-secondary" href={`/projects/${projectId}`}>
|
|
Zurück zu Verteilern
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-secondary mb-3">Aktiv geöffnet: {activeListCount} von maximal 3 Listen.</p>
|
|
|
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
|
|
|
{showColumnManager ? (
|
|
<section className="card shadow-sm mb-3">
|
|
<div className="card-header">Spaltenauswahl und Reihenfolge</div>
|
|
<div className="card-body">
|
|
<div className="row g-2">
|
|
{allColumns
|
|
.filter((column) => column.key !== "selection" && column.key !== "actions")
|
|
.map((column) => (
|
|
<div className="col-12 col-lg-6" key={column.key}>
|
|
<div className="d-flex align-items-center justify-content-between border rounded p-2">
|
|
<label className="form-check m-0">
|
|
<input
|
|
className="form-check-input me-2"
|
|
type="checkbox"
|
|
checked={visibleColumns.includes(column.key)}
|
|
onChange={() => toggleColumn(column.key)}
|
|
/>
|
|
<span className="form-check-label">{column.label}</span>
|
|
</label>
|
|
<div className="btn-group btn-group-sm">
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={() => moveColumn(column.key, -1)}
|
|
disabled={!visibleColumns.includes(column.key)}
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={() => moveColumn(column.key, 1)}
|
|
disabled={!visibleColumns.includes(column.key)}
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<small className="text-secondary d-block mt-2">
|
|
Standardmäßig sind cos f, Phasen und Strom ausgeblendet. Du kannst sie hier jederzeit einblenden.
|
|
</small>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<div className="row g-3">
|
|
{slots.slice(0, activeListCount).map((slot, slotIndex) => {
|
|
const currentFilter = listFilters[slotIndex];
|
|
const bulkEditForm = bulkEditForms[slotIndex];
|
|
const baseConsumersForSlot = slot.boardId ? consumersForBoard(slot.boardId) : [];
|
|
const query = currentFilter.query.trim().toLocaleLowerCase("de-DE");
|
|
const filteredConsumersForSlot = query
|
|
? baseConsumersForSlot.filter((consumer) => {
|
|
const searchable = [
|
|
consumer.circuitNumber ?? "",
|
|
consumer.description ?? "",
|
|
consumer.name ?? "",
|
|
consumer.category ?? "",
|
|
consumer.note ?? "",
|
|
consumer.roomNumber ?? "",
|
|
consumer.roomName ?? "",
|
|
consumer.floorName ?? "",
|
|
]
|
|
.join(" ")
|
|
.toLocaleLowerCase("de-DE");
|
|
return searchable.includes(query);
|
|
})
|
|
: baseConsumersForSlot;
|
|
const listConsumersForSlot = [...filteredConsumersForSlot].sort((a, b) => {
|
|
const aValue = getComparableSortValue(a, currentFilter.sortField);
|
|
const bValue = getComparableSortValue(b, currentFilter.sortField);
|
|
let result = 0;
|
|
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
result = aValue - bValue;
|
|
} else {
|
|
result = String(aValue).localeCompare(String(bValue), "de-DE", { sensitivity: "base" });
|
|
}
|
|
return currentFilter.sortDirection === "asc" ? result : result * -1;
|
|
});
|
|
const quickForm = quickCreateForms[slotIndex];
|
|
|
|
return (
|
|
<section className={slotColumnClass} key={slotIndex}>
|
|
<div className="card shadow-sm h-100">
|
|
<div className="card-header d-flex justify-content-between align-items-center">
|
|
<span>Liste {slotIndex + 1}</span>
|
|
<span className="badge text-bg-secondary">{listConsumersForSlot.length}</span>
|
|
</div>
|
|
<div className="card-body vstack gap-2">
|
|
<label className="form-label mb-0">Verteiler</label>
|
|
<select
|
|
className="form-select"
|
|
value={slot.boardId}
|
|
onChange={(event) => setSlotBoard(slotIndex, event.target.value)}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{boards.map((board) => (
|
|
<option key={board.id} value={board.id}>
|
|
{board.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<form className="row g-2" onSubmit={(event) => handleCreateManualConsumer(slotIndex, event)}>
|
|
<div className="col-12">
|
|
<input
|
|
className="form-control"
|
|
placeholder="Name Verbraucher (Anzeigename)"
|
|
value={quickForm.name}
|
|
onChange={(event) => updateQuickCreateForm(slotIndex, { name: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-lg-6">
|
|
<select
|
|
className="form-select"
|
|
value={quickForm.roomId}
|
|
onChange={(event) => updateQuickCreateForm(slotIndex, { roomId: event.target.value })}
|
|
>
|
|
<option value="">Kein Raum</option>
|
|
{rooms.map((room) => (
|
|
<option key={room.id} value={room.id}>
|
|
{roomLabel(room)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-6 col-lg-3">
|
|
<input
|
|
className="form-control"
|
|
type="number"
|
|
min="0"
|
|
value={quickForm.quantity}
|
|
onChange={(event) => updateQuickCreateForm(slotIndex, { quantity: event.target.value })}
|
|
placeholder="Anzahl"
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-3">
|
|
<input
|
|
className="form-control"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={quickForm.installedPowerPerUnitKw}
|
|
onChange={(event) =>
|
|
updateQuickCreateForm(slotIndex, { installedPowerPerUnitKw: event.target.value })
|
|
}
|
|
placeholder="Einzelleistung"
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-6">
|
|
<input
|
|
className="form-control"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={quickForm.demandFactor}
|
|
onChange={(event) => updateQuickCreateForm(slotIndex, { demandFactor: event.target.value })}
|
|
placeholder="Gleichzeitigkeitsfaktor"
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-6">
|
|
<input
|
|
className="form-control"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={quickForm.totalPowerKw}
|
|
onChange={(event) => updateTotalPower(slotIndex, event.target.value)}
|
|
placeholder="Gesamtleistung"
|
|
/>
|
|
</div>
|
|
<div className="col-12">
|
|
<button className="btn btn-primary w-100" type="submit" disabled={!slot.boardId || isSaving}>
|
|
Verbraucher anlegen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="input-group">
|
|
<select
|
|
className="form-select"
|
|
value={newDeviceTemplateId[slotIndex] ?? ""}
|
|
onChange={(event) =>
|
|
setNewDeviceTemplateId((current) => ({ ...current, [slotIndex]: event.target.value }))
|
|
}
|
|
>
|
|
<option value="">Projektgerät wählen</option>
|
|
{projectDevices.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className="form-control"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={quickForm.addCount}
|
|
onChange={(event) => updateQuickCreateForm(slotIndex, { addCount: event.target.value })}
|
|
title="Anzahl Einträge"
|
|
style={{ maxWidth: "8rem" }}
|
|
/>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={() => handleCreateFromProjectDevice(slotIndex)}
|
|
disabled={!slot.boardId || !newDeviceTemplateId[slotIndex] || isSaving}
|
|
>
|
|
Übernehmen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="table-responsive border rounded">
|
|
<div className="border-bottom p-2 bg-light-subtle">
|
|
<div className="row g-2">
|
|
<div className="col-12 col-xl-4">
|
|
<input
|
|
className="form-control form-control-sm"
|
|
placeholder="Filter (z. B. Name, Raum, Nummer)"
|
|
value={currentFilter.query}
|
|
onChange={(event) => updateListFilter(slotIndex, { query: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-xl-3">
|
|
<select
|
|
className="form-select form-select-sm"
|
|
value={currentFilter.sortField}
|
|
onChange={(event) =>
|
|
updateListFilter(slotIndex, { sortField: event.target.value as SortField })
|
|
}
|
|
>
|
|
<option value="name">Sortierung: Verbraucher</option>
|
|
<option value="circuitNumber">Sortierung: Stromkreis-Nr.</option>
|
|
<option value="quantity">Sortierung: Anzahl</option>
|
|
<option value="installedPowerPerUnitKw">Sortierung: Einzelleistung</option>
|
|
<option value="demandFactor">Sortierung: Gleichzeitigkeitsfaktor</option>
|
|
<option value="demandPowerKw">Sortierung: Gesamtleistung</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-6 col-xl-2">
|
|
<select
|
|
className="form-select form-select-sm"
|
|
value={currentFilter.sortDirection}
|
|
onChange={(event) =>
|
|
updateListFilter(slotIndex, { sortDirection: event.target.value as SortDirection })
|
|
}
|
|
>
|
|
<option value="asc">Aufsteigend</option>
|
|
<option value="desc">Absteigend</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-4 col-xl-1">
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
placeholder="Anzahl"
|
|
value={bulkEditForm.quantity}
|
|
onChange={(event) => updateBulkEditForm(slotIndex, { quantity: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="col-4 col-xl-1">
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="kW"
|
|
value={bulkEditForm.installedPowerPerUnitKw}
|
|
onChange={(event) =>
|
|
updateBulkEditForm(slotIndex, { installedPowerPerUnitKw: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="col-4 col-xl-1">
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
placeholder="GZF"
|
|
value={bulkEditForm.demandFactor}
|
|
onChange={(event) => updateBulkEditForm(slotIndex, { demandFactor: event.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="d-flex justify-content-between align-items-center mt-2">
|
|
<small className="text-secondary">
|
|
Gefiltert: {listConsumersForSlot.length} von {baseConsumersForSlot.length}
|
|
</small>
|
|
<button
|
|
className="btn btn-sm btn-outline-primary"
|
|
type="button"
|
|
onClick={() => handleBulkEditSelection(slotIndex)}
|
|
disabled={!slot.selectedConsumerIds.length || isSaving}
|
|
>
|
|
Auswahl ändern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<table className="table table-sm align-middle mb-0">
|
|
<thead className="table-light">
|
|
<tr>
|
|
{renderedColumns.map((column) => (
|
|
<th key={column.key}>{column.label}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{listConsumersForSlot.map((consumer) => (
|
|
<tr key={consumer.id}>
|
|
{renderedColumns.map((column) => {
|
|
switch (column.key) {
|
|
case "selection":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
checked={slot.selectedConsumerIds.includes(consumer.id)}
|
|
onChange={() => toggleConsumerSelection(slotIndex, consumer.id)}
|
|
/>
|
|
</td>
|
|
);
|
|
case "circuitNumber":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.circuitNumber ?? ""}
|
|
onBlur={(event) =>
|
|
event.target.value !== (consumer.circuitNumber ?? "")
|
|
? handleInlineUpdateFields(consumer, {
|
|
circuitNumber: event.target.value || undefined,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "description":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.description ?? consumer.name}
|
|
onBlur={(event) =>
|
|
event.target.value !== (consumer.description ?? consumer.name)
|
|
? handleInlineUpdateFields(consumer, {
|
|
description: event.target.value || undefined,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "name":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.name}
|
|
onBlur={(event) =>
|
|
event.target.value !== consumer.name
|
|
? handleInlineUpdateFields(consumer, { name: event.target.value })
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "projectDevice":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.projectDeviceId ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
projectDeviceId: event.target.value || undefined,
|
|
isLinkedToDevice: false,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Kein Projektgerät</option>
|
|
{projectDevices.map((device) => (
|
|
<option key={device.id} value={device.id}>
|
|
{device.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "deviceLink":
|
|
return (
|
|
<td key={column.key}>
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
type="button"
|
|
disabled={!consumer.projectDeviceId}
|
|
onClick={() =>
|
|
handleInlineUpdateFields(consumer, {
|
|
isLinkedToDevice: !consumer.isLinkedToDevice,
|
|
})
|
|
}
|
|
>
|
|
{consumer.isLinkedToDevice ? "Verknüpft" : "Entkoppelt"}
|
|
</button>
|
|
</td>
|
|
);
|
|
case "room":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.roomId ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
roomId: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Kein Raum</option>
|
|
{rooms.map((room) => (
|
|
<option key={room.id} value={room.id}>
|
|
{roomLabel(room)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "floor":
|
|
return <td key={column.key}>{consumer.floorName ?? "-"}</td>;
|
|
case "category":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.category ?? ""}
|
|
onBlur={(event) =>
|
|
event.target.value !== (consumer.category ?? "")
|
|
? handleInlineUpdateFields(consumer, {
|
|
category: event.target.value || undefined,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "deviceType":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.deviceType ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
deviceType: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{deviceTypeOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "phaseType":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.phaseType ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
phaseType: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{phaseTypeOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "tradeOrCostGroup":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.tradeOrCostGroup ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
tradeOrCostGroup: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{tradeOrCostGroupOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "group":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.group ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
group: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{consumerGroupOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "protectionType":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.protectionType ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
protectionType: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{protectionTypeOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "protectionRatedCurrent":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
defaultValue={consumer.protectionRatedCurrent ?? ""}
|
|
onBlur={(event) => {
|
|
const raw = event.target.value.trim();
|
|
const value = raw ? Number(raw) : undefined;
|
|
if (raw === "" && consumer.protectionRatedCurrent !== undefined) {
|
|
void handleInlineUpdateFields(consumer, {
|
|
protectionRatedCurrent: undefined,
|
|
});
|
|
} else if (
|
|
value !== undefined &&
|
|
!Number.isNaN(value) &&
|
|
value !== consumer.protectionRatedCurrent
|
|
) {
|
|
void handleInlineUpdateFields(consumer, { protectionRatedCurrent: value });
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
);
|
|
case "protectionCharacteristic":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.protectionCharacteristic ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
protectionCharacteristic: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{protectionCharacteristicOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "cableType":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.cableType ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
cableType: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{cableTypeOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "cableCrossSection":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={consumer.cableCrossSection ?? ""}
|
|
onChange={(event) =>
|
|
handleInlineUpdateFields(consumer, {
|
|
cableCrossSection: event.target.value || undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{cableCrossSectionOptions.map((value) => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
);
|
|
case "comment":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.comment ?? ""}
|
|
onBlur={(event) =>
|
|
event.target.value !== (consumer.comment ?? "")
|
|
? handleInlineUpdateFields(consumer, {
|
|
comment: event.target.value || undefined,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "quantity":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
defaultValue={consumer.quantity}
|
|
onBlur={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isNaN(value) && value !== consumer.quantity) {
|
|
void handleInlineUpdateFields(consumer, { quantity: value });
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
);
|
|
case "installedPowerPerUnitKw":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
defaultValue={consumer.installedPowerPerUnitKw}
|
|
onBlur={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isNaN(value) && value !== consumer.installedPowerPerUnitKw) {
|
|
void handleInlineUpdateFields(consumer, { installedPowerPerUnitKw: value });
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
);
|
|
case "installedPowerKw":
|
|
return <td key={column.key}>{formatNumber(consumer.installedPowerKw)}</td>;
|
|
case "demandFactor":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
defaultValue={consumer.demandFactor}
|
|
onBlur={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isNaN(value) && value !== consumer.demandFactor) {
|
|
void handleInlineUpdateFields(consumer, { demandFactor: value });
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
);
|
|
case "demandPowerKw":
|
|
return <td key={column.key}>{formatNumber(consumer.demandPowerKw)}</td>;
|
|
case "voltageV":
|
|
return <td key={column.key}>{formatNumber(consumer.effectiveVoltageV, 0)}</td>;
|
|
case "phaseCount":
|
|
return (
|
|
<td key={column.key}>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
defaultValue={String(consumer.phaseCount ?? 1)}
|
|
onChange={(event) => {
|
|
const value = event.target.value === "3" ? 3 : 1;
|
|
if (value !== consumer.phaseCount) {
|
|
void handleInlineUpdateFields(consumer, {
|
|
phaseCount: value,
|
|
voltageV: consumer.voltageV,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<option value="1">1</option>
|
|
<option value="3">3</option>
|
|
</select>
|
|
</td>
|
|
);
|
|
case "powerFactor":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
defaultValue={consumer.powerFactor ?? ""}
|
|
onBlur={(event) => {
|
|
const raw = event.target.value.trim();
|
|
const value = raw ? Number(raw) : undefined;
|
|
if (raw === "" && consumer.powerFactor !== undefined) {
|
|
void handleInlineUpdateFields(consumer, { powerFactor: undefined });
|
|
} else if (!Number.isNaN(value) && value !== consumer.powerFactor) {
|
|
void handleInlineUpdateFields(consumer, { powerFactor: value });
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
);
|
|
case "currentA":
|
|
return <td key={column.key}>{consumer.currentA?.toFixed(2) ?? "-"}</td>;
|
|
case "note":
|
|
return (
|
|
<td key={column.key}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
defaultValue={consumer.note ?? ""}
|
|
onBlur={(event) =>
|
|
event.target.value !== (consumer.note ?? "")
|
|
? handleInlineUpdateFields(consumer, { note: event.target.value || undefined })
|
|
: undefined
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
case "actions":
|
|
return (
|
|
<td key={column.key} className="text-nowrap">
|
|
<div className="btn-group btn-group-sm">
|
|
{slots.slice(0, activeListCount).map((_, targetSlotIndex) => (
|
|
<button
|
|
key={targetSlotIndex}
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
title={`Nach Liste ${targetSlotIndex + 1} kopieren`}
|
|
onClick={() => handleCopyToSlot(consumer, targetSlotIndex)}
|
|
disabled={
|
|
targetSlotIndex === slotIndex ||
|
|
!slots[targetSlotIndex].boardId ||
|
|
isSaving
|
|
}
|
|
>
|
|
L{targetSlotIndex + 1}
|
|
</button>
|
|
))}
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={() => handleDuplicateInSameList(consumer)}
|
|
disabled={isSaving}
|
|
title="In derselben Liste duplizieren"
|
|
>
|
|
Dupl.
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-danger"
|
|
type="button"
|
|
onClick={() => handleDeleteConsumer(consumer.id)}
|
|
disabled={isSaving}
|
|
>
|
|
X
|
|
</button>
|
|
</div>
|
|
</td>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</tr>
|
|
))}
|
|
{!listConsumersForSlot.length ? (
|
|
<tr>
|
|
<td colSpan={renderedColumns.length} className="text-center text-secondary py-3">
|
|
Keine Einträge für {slot.boardId ? boardById.get(slot.boardId)?.name : "diese Liste"}.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<small className="text-secondary">Ausgewählt: {slot.selectedConsumerIds.length}</small>
|
|
<div className="btn-group btn-group-sm">
|
|
{slots.slice(0, activeListCount).map((_, targetSlotIndex) => (
|
|
<button
|
|
key={`bulk-${targetSlotIndex}`}
|
|
className="btn btn-outline-primary"
|
|
type="button"
|
|
onClick={() => handleCopySelectionToSlot(slotIndex, targetSlotIndex)}
|
|
disabled={
|
|
targetSlotIndex === slotIndex ||
|
|
!slots[targetSlotIndex].boardId ||
|
|
!slot.selectedConsumerIds.length ||
|
|
isSaving
|
|
}
|
|
title={`Auswahl nach Liste ${targetSlotIndex + 1} kopieren`}
|
|
>
|
|
Auswahl → L{targetSlotIndex + 1}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
|