Streamline project management UI
This commit is contained in:
@@ -219,6 +219,13 @@ verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
|||||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||||
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
||||||
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
||||||
|
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||||
|
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||||
|
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||||
|
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||||
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import {
|
|||||||
ProjectSettingsModal,
|
ProjectSettingsModal,
|
||||||
type ProjectSettingsInput,
|
type ProjectSettingsInput,
|
||||||
} from "../../../frontend/components/project-settings-modal";
|
} from "../../../frontend/components/project-settings-modal";
|
||||||
|
import { FormModal } from "../../../frontend/components/form-modal";
|
||||||
|
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
||||||
|
|
||||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
name: "Technischer Name",
|
name: "Technischer Name",
|
||||||
@@ -59,28 +61,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
|||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
|
||||||
name: "",
|
|
||||||
displayName: "",
|
|
||||||
phaseType: "single_phase",
|
|
||||||
connectionKind: "",
|
|
||||||
costGroup: "",
|
|
||||||
category: "",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: 1,
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toOptionalNumber(value: string) {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return Number(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const params = useParams<{ projectId: string }>();
|
const params = useParams<{ projectId: string }>();
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
@@ -97,20 +77,14 @@ export default function ProjectDetailPage() {
|
|||||||
const [roomName, setRoomName] = useState("");
|
const [roomName, setRoomName] = useState("");
|
||||||
const [roomFloorId, setRoomFloorId] = useState("");
|
const [roomFloorId, setRoomFloorId] = useState("");
|
||||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||||
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({
|
const [structureModal, setStructureModal] = useState<
|
||||||
name: "",
|
"board" | "floor" | "room" | null
|
||||||
displayName: "",
|
>(null);
|
||||||
phaseType: "single_phase",
|
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
||||||
connectionKind: "",
|
useState(false);
|
||||||
costGroup: "",
|
const [editingProjectDevice, setEditingProjectDevice] =
|
||||||
category: "",
|
useState<ProjectDeviceDto | null>(null);
|
||||||
quantity: "1",
|
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
||||||
powerPerUnit: "0.1",
|
|
||||||
simultaneityFactor: "1",
|
|
||||||
cosPhi: "1",
|
|
||||||
remark: "",
|
|
||||||
});
|
|
||||||
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
|
||||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
@@ -162,6 +136,21 @@ export default function ProjectDetailPage() {
|
|||||||
const floorCount = useMemo(() => floors.length, [floors.length]);
|
const floorCount = useMemo(() => floors.length, [floors.length]);
|
||||||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||||||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||||||
|
const visibleProjectDevices = useMemo(() => {
|
||||||
|
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
||||||
|
if (!query) {
|
||||||
|
return projectDevices;
|
||||||
|
}
|
||||||
|
return projectDevices.filter((device) =>
|
||||||
|
[
|
||||||
|
device.name,
|
||||||
|
device.displayName,
|
||||||
|
device.category,
|
||||||
|
device.connectionKind,
|
||||||
|
device.costGroup,
|
||||||
|
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
||||||
|
);
|
||||||
|
}, [projectDeviceQuery, projectDevices]);
|
||||||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
||||||
const circuitListByBoardId = useMemo(
|
const circuitListByBoardId = useMemo(
|
||||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||||
@@ -194,6 +183,7 @@ export default function ProjectDetailPage() {
|
|||||||
setCircuitLists(await listCircuitLists(projectId));
|
setCircuitLists(await listCircuitLists(projectId));
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setBoardName("");
|
setBoardName("");
|
||||||
|
setStructureModal(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -217,6 +207,7 @@ export default function ProjectDetailPage() {
|
|||||||
setFloors((current) => [...current, result.floor]);
|
setFloors((current) => [...current, result.floor]);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setFloorName("");
|
setFloorName("");
|
||||||
|
setStructureModal(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -251,6 +242,7 @@ export default function ProjectDetailPage() {
|
|||||||
setRoomNumber("");
|
setRoomNumber("");
|
||||||
setRoomName("");
|
setRoomName("");
|
||||||
setRoomFloorId("");
|
setRoomFloorId("");
|
||||||
|
setStructureModal(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -279,26 +271,13 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateProjectDevice(
|
||||||
event.preventDefault();
|
payload: CreateProjectDeviceInput
|
||||||
if (!projectId || !project || !projectDeviceForm.name.trim()) {
|
) {
|
||||||
|
if (!projectId || !project || !payload.name.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: CreateProjectDeviceInput = {
|
|
||||||
name: projectDeviceForm.name.trim(),
|
|
||||||
displayName: projectDeviceForm.displayName.trim() || projectDeviceForm.name.trim(),
|
|
||||||
phaseType: projectDeviceForm.phaseType === "three_phase" ? "three_phase" : "single_phase",
|
|
||||||
connectionKind: projectDeviceForm.connectionKind.trim() || undefined,
|
|
||||||
costGroup: projectDeviceForm.costGroup.trim() || undefined,
|
|
||||||
category: projectDeviceForm.category.trim() || undefined,
|
|
||||||
quantity: Number(projectDeviceForm.quantity),
|
|
||||||
powerPerUnit: Number(projectDeviceForm.powerPerUnit),
|
|
||||||
simultaneityFactor: Number(projectDeviceForm.simultaneityFactor),
|
|
||||||
cosPhi: toOptionalNumber(projectDeviceForm.cosPhi),
|
|
||||||
remark: projectDeviceForm.remark.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -309,19 +288,7 @@ export default function ProjectDetailPage() {
|
|||||||
);
|
);
|
||||||
setProjectDevices((current) => [...current, result.projectDevice]);
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setProjectDeviceForm({
|
setIsProjectDeviceModalOpen(false);
|
||||||
name: emptyProjectDevice.name,
|
|
||||||
displayName: emptyProjectDevice.displayName,
|
|
||||||
phaseType: emptyProjectDevice.phaseType,
|
|
||||||
connectionKind: emptyProjectDevice.connectionKind ?? "",
|
|
||||||
costGroup: emptyProjectDevice.costGroup ?? "",
|
|
||||||
category: emptyProjectDevice.category ?? "",
|
|
||||||
quantity: String(emptyProjectDevice.quantity),
|
|
||||||
powerPerUnit: String(emptyProjectDevice.powerPerUnit),
|
|
||||||
simultaneityFactor: String(emptyProjectDevice.simultaneityFactor),
|
|
||||||
cosPhi: String(emptyProjectDevice.cosPhi ?? ""),
|
|
||||||
remark: emptyProjectDevice.remark ?? "",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -350,30 +317,16 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuickUpdateProjectDevice(
|
async function handleUpdateProjectDevice(
|
||||||
device: ProjectDeviceDto,
|
device: ProjectDeviceDto,
|
||||||
key: "name" | "displayName" | "category",
|
payload: CreateProjectDeviceInput
|
||||||
value: string
|
|
||||||
) {
|
) {
|
||||||
if (!projectId || !project) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: CreateProjectDeviceInput = {
|
setIsSaving(true);
|
||||||
name: key === "name" ? value : device.name,
|
setError(null);
|
||||||
displayName: key === "displayName" ? value : device.displayName,
|
|
||||||
phaseType: device.phaseType,
|
|
||||||
connectionKind: device.connectionKind ?? undefined,
|
|
||||||
costGroup: device.costGroup ?? undefined,
|
|
||||||
category: key === "category" ? value : device.category ?? undefined,
|
|
||||||
quantity: device.quantity,
|
|
||||||
powerPerUnit: device.powerPerUnit,
|
|
||||||
simultaneityFactor: device.simultaneityFactor,
|
|
||||||
cosPhi: device.cosPhi ?? undefined,
|
|
||||||
remark: device.remark ?? undefined,
|
|
||||||
voltageV: device.voltageV ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await updateProjectDevice(
|
const result = await updateProjectDevice(
|
||||||
projectId,
|
projectId,
|
||||||
@@ -387,9 +340,12 @@ export default function ProjectDetailPage() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setEditingProjectDevice(null);
|
||||||
await openProjectDeviceSyncPreview(result.projectDevice.id);
|
await openProjectDeviceSyncPreview(result.projectDevice.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,8 +444,8 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject(globalDeviceId: string) {
|
||||||
if (!projectId || !project || !selectedGlobalDeviceId) {
|
if (!projectId || !project || !globalDeviceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,11 +454,12 @@ export default function ProjectDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await copyGlobalDeviceToProject(
|
const result = await copyGlobalDeviceToProject(
|
||||||
projectId,
|
projectId,
|
||||||
selectedGlobalDeviceId,
|
globalDeviceId,
|
||||||
project.currentRevision
|
project.currentRevision
|
||||||
);
|
);
|
||||||
setProjectDevices((current) => [...current, result.projectDevice]);
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setIsProjectDeviceModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -567,30 +524,20 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="col-12 col-lg-4">
|
<section className="col-12">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header">Neue Verteilung</div>
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div className="card-body">
|
<div>
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateBoard}>
|
<span>Verteilungen</span>
|
||||||
<input
|
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
||||||
className="form-control"
|
</div>
|
||||||
placeholder="z. B. UV-01"
|
<button
|
||||||
value={boardName}
|
className="btn btn-sm btn-primary"
|
||||||
onChange={(event) => setBoardName(event.target.value)}
|
onClick={() => setStructureModal("board")}
|
||||||
/>
|
type="button"
|
||||||
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
>
|
||||||
Verteilung erstellen
|
Verteilung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="col-12 col-lg-8">
|
|
||||||
<div className="card shadow-sm">
|
|
||||||
<div className="card-header d-flex justify-content-between">
|
|
||||||
<span>Alle Verteilungen</span>
|
|
||||||
<span className="badge text-bg-secondary">{boardCount}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-hover align-middle mb-0">
|
<table className="table table-hover align-middle mb-0">
|
||||||
@@ -638,24 +585,20 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-xl-4">
|
<section className="col-12 col-lg-5">
|
||||||
<div className="card shadow-sm h-100">
|
<div className="card shadow-sm h-100">
|
||||||
<div className="card-header d-flex justify-content-between">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
<span>Etagen</span>
|
<span>Etagen</span>
|
||||||
<span className="badge text-bg-secondary">{floorCount}</span>
|
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body border-bottom">
|
<button
|
||||||
<form className="vstack gap-2" onSubmit={handleCreateFloor}>
|
className="btn btn-sm btn-outline-primary"
|
||||||
<input
|
onClick={() => setStructureModal("floor")}
|
||||||
className="form-control"
|
type="button"
|
||||||
placeholder="z. B. EG"
|
>
|
||||||
value={floorName}
|
Hinzufügen
|
||||||
onChange={(event) => setFloorName(event.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
|
||||||
Etage hinzufügen
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-group list-group-flush">
|
<ul className="list-group list-group-flush">
|
||||||
{floors.map((floor) => (
|
{floors.map((floor) => (
|
||||||
@@ -670,51 +613,21 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-xl-8">
|
<section className="col-12 col-lg-7">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header d-flex justify-content-between">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
<span>Räume</span>
|
<span>Räume</span>
|
||||||
<span className="badge text-bg-secondary">{roomCount}</span>
|
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body border-bottom">
|
<button
|
||||||
<form className="row g-2" onSubmit={handleCreateRoom}>
|
className="btn btn-sm btn-outline-primary"
|
||||||
<div className="col-12 col-lg-3">
|
onClick={() => setStructureModal("room")}
|
||||||
<input
|
type="button"
|
||||||
className="form-control"
|
|
||||||
placeholder="Raumnummer"
|
|
||||||
value={roomNumber}
|
|
||||||
onChange={(event) => setRoomNumber(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-lg-4">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Raumname"
|
|
||||||
value={roomName}
|
|
||||||
onChange={(event) => setRoomName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-lg-3">
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={roomFloorId}
|
|
||||||
onChange={(event) => setRoomFloorId(event.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="">Ohne Etage</option>
|
Hinzufügen
|
||||||
{floors.map((floor) => (
|
|
||||||
<option key={floor.id} value={floor.id}>
|
|
||||||
{floor.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-lg-2">
|
|
||||||
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
|
|
||||||
Raum anlegen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-hover align-middle mb-0">
|
<table className="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -747,167 +660,26 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card shadow-sm mt-4">
|
<section className="card shadow-sm mt-4">
|
||||||
<div className="card-header d-flex justify-content-between">
|
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<span>Projektgeräte / Verbraucher</span>
|
<div>
|
||||||
<span className="badge text-bg-secondary">{projectDeviceCount}</span>
|
<span>Projektgeräte</span>
|
||||||
|
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body border-bottom">
|
<div className="d-flex gap-2">
|
||||||
<form className="row g-2" onSubmit={handleCreateProjectDevice}>
|
|
||||||
<div className="col-12 col-md-2">
|
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
aria-label="Projektgeräte durchsuchen"
|
||||||
placeholder="Interner Name"
|
className="form-control form-control-sm"
|
||||||
value={projectDeviceForm.name}
|
onChange={(event) => setProjectDeviceQuery(event.target.value)}
|
||||||
onChange={(event) =>
|
placeholder="Geräte durchsuchen"
|
||||||
setProjectDeviceForm((current) => ({ ...current, name: event.target.value }))
|
type="search"
|
||||||
}
|
value={projectDeviceQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-2">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Anzeigename"
|
|
||||||
value={projectDeviceForm.displayName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, displayName: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Kategorie"
|
|
||||||
value={projectDeviceForm.category}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, category: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Anschlussart"
|
|
||||||
value={projectDeviceForm.connectionKind}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Kostengruppe"
|
|
||||||
value={projectDeviceForm.costGroup}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
title="Anzahl"
|
|
||||||
value={projectDeviceForm.quantity}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
title="Leistung je Stück [kW]"
|
|
||||||
value={projectDeviceForm.powerPerUnit}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({
|
|
||||||
...current,
|
|
||||||
powerPerUnit: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
title="Gleichzeitigkeitsfaktor"
|
|
||||||
value={projectDeviceForm.simultaneityFactor}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, simultaneityFactor: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
title="cos Phi"
|
|
||||||
value={projectDeviceForm.cosPhi}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-1">
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
title="Phasenart"
|
|
||||||
value={projectDeviceForm.phaseType}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, phaseType: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="single_phase">1-ph</option>
|
|
||||||
<option value="three_phase">3-ph</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-10">
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Bemerkung"
|
|
||||||
value={projectDeviceForm.remark}
|
|
||||||
onChange={(event) =>
|
|
||||||
setProjectDeviceForm((current) => ({ ...current, remark: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-2">
|
|
||||||
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
|
|
||||||
Gerät anlegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="input-group mt-3">
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={selectedGlobalDeviceId}
|
|
||||||
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Globales Gerät auswählen</option>
|
|
||||||
{globalDevices.map((device) => (
|
|
||||||
<option key={device.id} value={device.id}>
|
|
||||||
{device.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-sm btn-primary text-nowrap"
|
||||||
|
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyGlobalToProject}
|
|
||||||
disabled={!selectedGlobalDeviceId || isSaving}
|
|
||||||
>
|
>
|
||||||
Nach Projekt kopieren
|
Gerät hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -915,64 +687,51 @@ export default function ProjectDetailPage() {
|
|||||||
<table className="table table-sm table-striped align-middle mb-0">
|
<table className="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Interner Name</th>
|
<th>Gerät</th>
|
||||||
<th>Anzeigename</th>
|
<th>Anschluss</th>
|
||||||
<th>Phasenart</th>
|
<th>Leistung</th>
|
||||||
<th>Kategorie</th>
|
|
||||||
<th>Kostengruppe</th>
|
<th>Kostengruppe</th>
|
||||||
<th>Anzahl</th>
|
<th className="text-end">Aktionen</th>
|
||||||
<th>Leistung je Stück [kW]</th>
|
|
||||||
<th>GZF</th>
|
|
||||||
<th>Gesamtleistung [kW]</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{projectDevices.map((device) => (
|
{visibleProjectDevices.map((device) => (
|
||||||
<tr key={device.id}>
|
<tr key={device.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<strong>{device.displayName}</strong>
|
||||||
className="form-control form-control-sm"
|
<div className="small text-secondary">
|
||||||
defaultValue={device.name}
|
{device.name}
|
||||||
onBlur={(event) =>
|
{device.category ? ` · ${device.category}` : ""}
|
||||||
event.target.value !== device.name
|
</div>
|
||||||
? handleQuickUpdateProjectDevice(device, "name", event.target.value)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
||||||
className="form-control form-control-sm"
|
{device.connectionKind ? (
|
||||||
defaultValue={device.displayName}
|
<div className="small text-secondary">
|
||||||
onBlur={(event) =>
|
{device.connectionKind}
|
||||||
event.target.value !== device.displayName
|
</div>
|
||||||
? handleQuickUpdateProjectDevice(device, "displayName", event.target.value)
|
) : null}
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{device.phaseType === "three_phase" ? "3-ph" : "1-ph"}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<input
|
{device.totalPower} kW
|
||||||
className="form-control form-control-sm"
|
<div className="small text-secondary">
|
||||||
defaultValue={device.category ?? ""}
|
{device.quantity} × {device.powerPerUnit} kW · GZF{" "}
|
||||||
onBlur={(event) =>
|
{device.simultaneityFactor}
|
||||||
event.target.value !== (device.category ?? "")
|
</div>
|
||||||
? handleQuickUpdateProjectDevice(device, "category", event.target.value)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{device.costGroup ?? "-"}</td>
|
<td>{device.costGroup ?? "-"}</td>
|
||||||
<td>{device.quantity}</td>
|
<td className="text-end">
|
||||||
<td>{device.powerPerUnit}</td>
|
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
|
||||||
<td>{device.simultaneityFactor}</td>
|
|
||||||
<td>{device.totalPower}</td>
|
|
||||||
<td>
|
|
||||||
<div className="btn-group btn-group-sm">
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingProjectDevice(device)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -980,7 +739,7 @@ export default function ProjectDetailPage() {
|
|||||||
Verknüpfungen
|
Verknüpfungen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-sm btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
onClick={() => handleCopyProjectToGlobal(device.id)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -988,7 +747,7 @@ export default function ProjectDetailPage() {
|
|||||||
Nach global kopieren
|
Nach global kopieren
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-danger"
|
className="btn btn-sm btn-outline-danger"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteProjectDevice(device.id)}
|
onClick={() => handleDeleteProjectDevice(device.id)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -999,10 +758,12 @@ export default function ProjectDetailPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!projectDevices.length ? (
|
{!visibleProjectDevices.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="text-center text-secondary py-4">
|
<td colSpan={5} className="text-center text-secondary py-4">
|
||||||
Noch keine Projektgeräte vorhanden.
|
{projectDevices.length
|
||||||
|
? "Keine Projektgeräte entsprechen der Suche."
|
||||||
|
: "Noch keine Projektgeräte vorhanden."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1121,6 +882,135 @@ export default function ProjectDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{structureModal === "board" ? (
|
||||||
|
<FormModal
|
||||||
|
description="Eine Stromkreisliste mit den vier Standardbereichen wird automatisch angelegt."
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setStructureModal(null)}
|
||||||
|
onSubmit={handleCreateBoard}
|
||||||
|
submitDisabled={!boardName.trim()}
|
||||||
|
submitLabel="Verteilung erstellen"
|
||||||
|
title="Verteilung hinzufügen"
|
||||||
|
>
|
||||||
|
<label className="form-label" htmlFor="distribution-board-name">
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="form-control"
|
||||||
|
id="distribution-board-name"
|
||||||
|
onChange={(event) => setBoardName(event.target.value)}
|
||||||
|
placeholder="z. B. UV-01"
|
||||||
|
required
|
||||||
|
value={boardName}
|
||||||
|
/>
|
||||||
|
</FormModal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{structureModal === "floor" ? (
|
||||||
|
<FormModal
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setStructureModal(null)}
|
||||||
|
onSubmit={handleCreateFloor}
|
||||||
|
submitDisabled={!floorName.trim()}
|
||||||
|
submitLabel="Etage hinzufügen"
|
||||||
|
title="Etage hinzufügen"
|
||||||
|
>
|
||||||
|
<label className="form-label" htmlFor="floor-name">
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="form-control"
|
||||||
|
id="floor-name"
|
||||||
|
onChange={(event) => setFloorName(event.target.value)}
|
||||||
|
placeholder="z. B. EG"
|
||||||
|
required
|
||||||
|
value={floorName}
|
||||||
|
/>
|
||||||
|
</FormModal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{structureModal === "room" ? (
|
||||||
|
<FormModal
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setStructureModal(null)}
|
||||||
|
onSubmit={handleCreateRoom}
|
||||||
|
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
||||||
|
submitLabel="Raum anlegen"
|
||||||
|
title="Raum hinzufügen"
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label" htmlFor="room-number">
|
||||||
|
Raumnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="form-control"
|
||||||
|
id="room-number"
|
||||||
|
onChange={(event) => setRoomNumber(event.target.value)}
|
||||||
|
required
|
||||||
|
value={roomNumber}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-8">
|
||||||
|
<label className="form-label" htmlFor="room-name">
|
||||||
|
Raumname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="room-name"
|
||||||
|
onChange={(event) => setRoomName(event.target.value)}
|
||||||
|
required
|
||||||
|
value={roomName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="room-floor">
|
||||||
|
Etage
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="room-floor"
|
||||||
|
onChange={(event) => setRoomFloorId(event.target.value)}
|
||||||
|
value={roomFloorId}
|
||||||
|
>
|
||||||
|
<option value="">Ohne Etage</option>
|
||||||
|
{floors.map((floor) => (
|
||||||
|
<option key={floor.id} value={floor.id}>
|
||||||
|
{floor.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isProjectDeviceModalOpen ? (
|
||||||
|
<ProjectDeviceModal
|
||||||
|
globalDevices={globalDevices}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setIsProjectDeviceModalOpen(false)}
|
||||||
|
onImportGlobal={handleCopyGlobalToProject}
|
||||||
|
onSave={handleCreateProjectDevice}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editingProjectDevice ? (
|
||||||
|
<ProjectDeviceModal
|
||||||
|
globalDevices={globalDevices}
|
||||||
|
initialDevice={editingProjectDevice}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setEditingProjectDevice(null)}
|
||||||
|
onImportGlobal={handleCopyGlobalToProject}
|
||||||
|
onSave={(input) =>
|
||||||
|
handleUpdateProjectDevice(editingProjectDevice, input)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{project && isProjectSettingsOpen ? (
|
{project && isProjectSettingsOpen ? (
|
||||||
<ProjectSettingsModal
|
<ProjectSettingsModal
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { type FormEvent, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface FormModalProps {
|
||||||
|
children: ReactNode;
|
||||||
|
description?: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
submitLabel: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormModal({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
submitDisabled,
|
||||||
|
submitLabel,
|
||||||
|
title,
|
||||||
|
}: FormModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-modal="true"
|
||||||
|
className="modal fade show d-block"
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<form className="modal-content" onSubmit={onSubmit}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title fs-5">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-secondary small mb-0">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="btn-close"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSaving || submitDisabled}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSaving ? "Wird gespeichert …" : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop fade show" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FormEvent, useState } from "react";
|
||||||
|
import type {
|
||||||
|
CreateProjectDeviceInput,
|
||||||
|
GlobalDeviceDto,
|
||||||
|
ProjectDeviceDto,
|
||||||
|
} from "../types";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
interface ProjectDeviceModalProps {
|
||||||
|
globalDevices: GlobalDeviceDto[];
|
||||||
|
initialDevice?: ProjectDeviceDto;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
||||||
|
onSave: (input: CreateProjectDeviceInput) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDeviceModal({
|
||||||
|
globalDevices,
|
||||||
|
initialDevice,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onImportGlobal,
|
||||||
|
onSave,
|
||||||
|
}: ProjectDeviceModalProps) {
|
||||||
|
const [values, setValues] = useState(() => toFormValues(initialDevice));
|
||||||
|
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||||
|
|
||||||
|
function update(key: keyof typeof values, value: string) {
|
||||||
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onSave({
|
||||||
|
name: values.name.trim(),
|
||||||
|
displayName: values.displayName.trim() || values.name.trim(),
|
||||||
|
phaseType:
|
||||||
|
values.phaseType === "three_phase" ? "three_phase" : "single_phase",
|
||||||
|
connectionKind: optionalString(values.connectionKind),
|
||||||
|
costGroup: optionalString(values.costGroup),
|
||||||
|
category: optionalString(values.category),
|
||||||
|
quantity: Number(values.quantity),
|
||||||
|
powerPerUnit: Number(values.powerPerUnit),
|
||||||
|
simultaneityFactor: Number(values.simultaneityFactor),
|
||||||
|
cosPhi: optionalNumber(values.cosPhi),
|
||||||
|
remark: optionalString(values.remark),
|
||||||
|
voltageV: optionalNumber(values.voltageV),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
values.name.trim().length > 0 &&
|
||||||
|
Number(values.quantity) >= 0 &&
|
||||||
|
Number(values.powerPerUnit) >= 0 &&
|
||||||
|
Number(values.simultaneityFactor) >= 0 &&
|
||||||
|
Number(values.simultaneityFactor) <= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
description={
|
||||||
|
initialDevice
|
||||||
|
? "Kanonische Gerätedaten bearbeiten; verknüpfte Stromkreiszeilen werden nicht automatisch überschrieben."
|
||||||
|
: "Ein Gerät manuell anlegen oder aus der globalen Bibliothek übernehmen."
|
||||||
|
}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitDisabled={!isValid}
|
||||||
|
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
||||||
|
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
|
||||||
|
>
|
||||||
|
{!initialDevice && globalDevices.length > 0 ? (
|
||||||
|
<div className="border rounded p-3 mb-4">
|
||||||
|
<label className="form-label" htmlFor="global-project-device">
|
||||||
|
Aus globaler Gerätebibliothek
|
||||||
|
</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="global-project-device"
|
||||||
|
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
|
||||||
|
value={selectedGlobalDeviceId}
|
||||||
|
>
|
||||||
|
<option value="">Gerät auswählen</option>
|
||||||
|
{globalDevices.map((device) => (
|
||||||
|
<option key={device.id} value={device.id}>
|
||||||
|
{device.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={isSaving || !selectedGlobalDeviceId}
|
||||||
|
onClick={() => void onImportGlobal(selectedGlobalDeviceId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<TextField
|
||||||
|
id="device-name"
|
||||||
|
label="Interner Name"
|
||||||
|
onChange={(value) => update("name", value)}
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-display-name"
|
||||||
|
label="Anzeigename"
|
||||||
|
onChange={(value) => update("displayName", value)}
|
||||||
|
value={values.displayName}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-category"
|
||||||
|
label="Kategorie"
|
||||||
|
onChange={(value) => update("category", value)}
|
||||||
|
value={values.category}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-connection"
|
||||||
|
label="Anschlussart"
|
||||||
|
onChange={(value) => update("connectionKind", value)}
|
||||||
|
value={values.connectionKind}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-cost-group"
|
||||||
|
label="Kostengruppe"
|
||||||
|
onChange={(value) => update("costGroup", value)}
|
||||||
|
value={values.costGroup}
|
||||||
|
/>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="device-phase-type">
|
||||||
|
Phasenart
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="device-phase-type"
|
||||||
|
onChange={(event) => update("phaseType", event.target.value)}
|
||||||
|
value={values.phaseType}
|
||||||
|
>
|
||||||
|
<option value="single_phase">1-phasig</option>
|
||||||
|
<option value="three_phase">3-phasig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<NumberField
|
||||||
|
id="device-quantity"
|
||||||
|
label="Anzahl"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("quantity", value)}
|
||||||
|
value={values.quantity}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-power"
|
||||||
|
label="Leistung je Stück [kW]"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("powerPerUnit", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.powerPerUnit}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-simultaneity"
|
||||||
|
label="Gleichzeitigkeitsfaktor"
|
||||||
|
max="1"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("simultaneityFactor", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.simultaneityFactor}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-cos-phi"
|
||||||
|
label="cos Phi"
|
||||||
|
max="1"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("cosPhi", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.cosPhi}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-voltage"
|
||||||
|
label="Spannung [V]"
|
||||||
|
min="1"
|
||||||
|
onChange={(value) => update("voltageV", value)}
|
||||||
|
value={values.voltageV}
|
||||||
|
/>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="device-remark">
|
||||||
|
Bemerkung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="device-remark"
|
||||||
|
onChange={(event) => update("remark", event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
value={values.remark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ id, label, onChange, required, value }: FieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id={id}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
required={required}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
onChange,
|
||||||
|
step,
|
||||||
|
value,
|
||||||
|
}: FieldProps & { max?: string; min?: string; step?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id={id}
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
step={step}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(device?: ProjectDeviceDto) {
|
||||||
|
return {
|
||||||
|
name: device?.name ?? "",
|
||||||
|
displayName: device?.displayName ?? "",
|
||||||
|
phaseType: device?.phaseType ?? "single_phase",
|
||||||
|
connectionKind: device?.connectionKind ?? "",
|
||||||
|
costGroup: device?.costGroup ?? "",
|
||||||
|
category: device?.category ?? "",
|
||||||
|
quantity: String(device?.quantity ?? 1),
|
||||||
|
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
|
||||||
|
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
|
||||||
|
cosPhi: String(device?.cosPhi ?? 1),
|
||||||
|
remark: device?.remark ?? "",
|
||||||
|
voltageV: String(device?.voltageV ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: string) {
|
||||||
|
return value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(value: string) {
|
||||||
|
return value.trim() ? Number(value) : undefined;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
|
|||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
||||||
|
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
|
||||||
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
|
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
|
||||||
import {
|
import {
|
||||||
createNamedProjectSnapshot,
|
createNamedProjectSnapshot,
|
||||||
@@ -98,6 +99,36 @@ describe("project version history presentation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("project device modal presentation", () => {
|
||||||
|
it("renders explicit German labels for every editable device field", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(ProjectDeviceModal, {
|
||||||
|
globalDevices: [],
|
||||||
|
isSaving: false,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onImportGlobal: async () => undefined,
|
||||||
|
onSave: async () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const label of [
|
||||||
|
"Projektgerät hinzufügen",
|
||||||
|
"Interner Name",
|
||||||
|
"Anzeigename",
|
||||||
|
"Kategorie",
|
||||||
|
"Anschlussart",
|
||||||
|
"Kostengruppe",
|
||||||
|
"Phasenart",
|
||||||
|
"Anzahl",
|
||||||
|
"Leistung je Stück [kW]",
|
||||||
|
"Gleichzeitigkeitsfaktor",
|
||||||
|
"Spannung [V]",
|
||||||
|
"Bemerkung",
|
||||||
|
]) {
|
||||||
|
assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("project version history API", () => {
|
describe("project version history API", () => {
|
||||||
it("uses the revision-safe timeline and snapshot routes", async () => {
|
it("uses the revision-safe timeline and snapshot routes", async () => {
|
||||||
const requests: Array<{
|
const requests: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user