- Project settings gain siteAddress, workStage, projectLead and projectEditor (project.update-settings schema version 3, migration 0009); versions 2 and 1 stay executable and keep the current cover values. Snapshot schema 6 carries the fields; older versions upgrade with null. - Global office profile (office name/address) next to the document logo: table document_branding_settings, PUT /api/document-branding/office, editable from the print view toolbar. - Cover page reads all fields; empty values show the "[ergänzen]" marker. The Planstände editor column shows the project editor. - Shared logo constants moved to a dependency-free module so the Next.js bundler can import them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import React, { FormEvent, useState } from "react";
|
||
import type { ProjectDto } from "../types";
|
||
import {
|
||
distributionBoardSupplyTypeLabels,
|
||
distributionBoardSupplyTypes,
|
||
type DistributionBoardSupplyType,
|
||
} from "../../shared/constants/distribution-board";
|
||
import {
|
||
projectCoverFieldLabels,
|
||
projectCoverFieldMaxLengths,
|
||
projectCoverFields,
|
||
type ProjectCoverField,
|
||
} from "../../shared/constants/project-cover";
|
||
import { FormModal } from "./form-modal";
|
||
|
||
export interface ProjectSettingsInput
|
||
extends Record<ProjectCoverField, string | null> {
|
||
name: string;
|
||
internalProjectNumber: string | null;
|
||
externalProjectNumber: string | null;
|
||
buildingOwner: string | null;
|
||
description: string | null;
|
||
isPublicBuilding: boolean;
|
||
singlePhaseVoltageV: number;
|
||
threePhaseVoltageV: number;
|
||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||
}
|
||
|
||
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
|
||
siteAddress: "Straße Nr.\nPLZ Ort",
|
||
workStage: "z. B. LPH 5 – Ausführungsplanung",
|
||
projectLead: "",
|
||
projectEditor: "",
|
||
};
|
||
|
||
interface ProjectSettingsModalProps {
|
||
isSaving: boolean;
|
||
onClose: () => void;
|
||
onExport: () => Promise<void>;
|
||
onImport: (
|
||
transfer: unknown,
|
||
mode: "replace" | "duplicate"
|
||
) => Promise<void>;
|
||
onSave: (input: ProjectSettingsInput) => Promise<void>;
|
||
project: ProjectDto;
|
||
usedDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||
}
|
||
|
||
export function ProjectSettingsModal({
|
||
isSaving,
|
||
onClose,
|
||
onExport,
|
||
onImport,
|
||
onSave,
|
||
project,
|
||
usedDistributionBoardSupplyTypes,
|
||
}: ProjectSettingsModalProps) {
|
||
const [name, setName] = useState(project.name);
|
||
const [internalProjectNumber, setInternalProjectNumber] = useState(
|
||
project.internalProjectNumber ?? ""
|
||
);
|
||
const [externalProjectNumber, setExternalProjectNumber] = useState(
|
||
project.externalProjectNumber ?? ""
|
||
);
|
||
const [buildingOwner, setBuildingOwner] = useState(
|
||
project.buildingOwner ?? ""
|
||
);
|
||
const [description, setDescription] = useState(
|
||
project.description ?? ""
|
||
);
|
||
const [isPublicBuilding, setIsPublicBuilding] = useState(
|
||
project.isPublicBuilding
|
||
);
|
||
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState(
|
||
String(project.singlePhaseVoltageV)
|
||
);
|
||
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
|
||
String(project.threePhaseVoltageV)
|
||
);
|
||
const [
|
||
enabledDistributionBoardSupplyTypes,
|
||
setEnabledDistributionBoardSupplyTypes,
|
||
] = useState<DistributionBoardSupplyType[]>(
|
||
project.enabledDistributionBoardSupplyTypes
|
||
);
|
||
const [coverValues, setCoverValues] = useState<
|
||
Record<ProjectCoverField, string>
|
||
>({
|
||
siteAddress: project.siteAddress ?? "",
|
||
workStage: project.workStage ?? "",
|
||
projectLead: project.projectLead ?? "",
|
||
projectEditor: project.projectEditor ?? "",
|
||
});
|
||
const [transfer, setTransfer] = useState<unknown>(null);
|
||
const [transferFilename, setTransferFilename] = useState("");
|
||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||
"duplicate"
|
||
);
|
||
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
|
||
const [fileError, setFileError] = useState<string | null>(null);
|
||
|
||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
await onSave({
|
||
name: name.trim(),
|
||
internalProjectNumber: toNullableString(internalProjectNumber),
|
||
externalProjectNumber: toNullableString(externalProjectNumber),
|
||
buildingOwner: toNullableString(buildingOwner),
|
||
description: toNullableString(description),
|
||
isPublicBuilding,
|
||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||
enabledDistributionBoardSupplyTypes,
|
||
siteAddress: toNullableString(coverValues.siteAddress),
|
||
workStage: toNullableString(coverValues.workStage),
|
||
projectLead: toNullableString(coverValues.projectLead),
|
||
projectEditor: toNullableString(coverValues.projectEditor),
|
||
});
|
||
}
|
||
|
||
const isValid =
|
||
name.trim().length > 0 &&
|
||
Number(singlePhaseVoltageV) > 0 &&
|
||
Number(threePhaseVoltageV) > 0 &&
|
||
enabledDistributionBoardSupplyTypes.length > 0;
|
||
|
||
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
|
||
if (
|
||
enabledDistributionBoardSupplyTypes.includes(supplyType) &&
|
||
usedDistributionBoardSupplyTypes.includes(supplyType)
|
||
) {
|
||
return;
|
||
}
|
||
setEnabledDistributionBoardSupplyTypes((current) =>
|
||
current.includes(supplyType)
|
||
? current.filter((entry) => entry !== supplyType)
|
||
: distributionBoardSupplyTypes.filter(
|
||
(entry) => entry === supplyType || current.includes(entry)
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleTransferFile(file: File | undefined) {
|
||
setTransfer(null);
|
||
setTransferFilename("");
|
||
setFileError(null);
|
||
if (!file) {
|
||
return;
|
||
}
|
||
try {
|
||
setTransfer(JSON.parse(await file.text()) as unknown);
|
||
setTransferFilename(file.name);
|
||
} catch {
|
||
setFileError("Die ausgewählte Datei enthält kein gültiges JSON.");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<FormModal
|
||
description="Stammdaten und elektrische Standardwerte des Projekts"
|
||
isSaving={isSaving}
|
||
onClose={onClose}
|
||
onSubmit={handleSubmit}
|
||
submitDisabled={!isValid}
|
||
submitLabel="Einstellungen speichern"
|
||
title="Projekteinstellungen"
|
||
>
|
||
<div className="row g-3">
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="project-name">
|
||
Projektname
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
className="form-control"
|
||
id="project-name"
|
||
maxLength={200}
|
||
onChange={(event) => setName(event.target.value)}
|
||
required
|
||
value={name}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label" htmlFor="internal-project-number">
|
||
Projektnummer intern
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="internal-project-number"
|
||
maxLength={100}
|
||
onChange={(event) =>
|
||
setInternalProjectNumber(event.target.value)
|
||
}
|
||
value={internalProjectNumber}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label" htmlFor="external-project-number">
|
||
Projektnummer extern
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="external-project-number"
|
||
maxLength={100}
|
||
onChange={(event) =>
|
||
setExternalProjectNumber(event.target.value)
|
||
}
|
||
value={externalProjectNumber}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<fieldset>
|
||
<legend className="form-label mb-1">
|
||
Verwendete Netzarten
|
||
</legend>
|
||
<p className="form-text mt-0">
|
||
Nur ausgewählte Netzarten stehen bei Verteilungen zur
|
||
Auswahl. Bereits verwendete Netzarten können nicht
|
||
deaktiviert werden.
|
||
</p>
|
||
<div className="row g-2">
|
||
{distributionBoardSupplyTypes.map((supplyType) => (
|
||
<div
|
||
className="col-12 col-md-6"
|
||
key={supplyType}
|
||
>
|
||
<label className="form-check">
|
||
<input
|
||
checked={enabledDistributionBoardSupplyTypes.includes(
|
||
supplyType
|
||
)}
|
||
className="form-check-input"
|
||
disabled={usedDistributionBoardSupplyTypes.includes(
|
||
supplyType
|
||
)}
|
||
onChange={() => toggleSupplyType(supplyType)}
|
||
type="checkbox"
|
||
/>
|
||
<span className="form-check-label">
|
||
{distributionBoardSupplyTypeLabels[supplyType]}
|
||
{usedDistributionBoardSupplyTypes.includes(
|
||
supplyType
|
||
)
|
||
? " (in Verwendung)"
|
||
: ""}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{enabledDistributionBoardSupplyTypes.length === 0 ? (
|
||
<div className="text-danger small mt-2">
|
||
Mindestens eine Netzart muss aktiviert sein.
|
||
</div>
|
||
) : null}
|
||
</fieldset>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="building-owner">
|
||
Bauherr
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="building-owner"
|
||
maxLength={200}
|
||
onChange={(event) => setBuildingOwner(event.target.value)}
|
||
value={buildingOwner}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="project-description">
|
||
Beschreibung
|
||
</label>
|
||
<textarea
|
||
className="form-control"
|
||
id="project-description"
|
||
maxLength={2000}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
rows={4}
|
||
value={description}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-check">
|
||
<input
|
||
checked={isPublicBuilding}
|
||
className="form-check-input"
|
||
id="public-building"
|
||
onChange={(event) =>
|
||
setIsPublicBuilding(event.target.checked)
|
||
}
|
||
type="checkbox"
|
||
/>
|
||
<span className="form-check-label">
|
||
Öffentliches Gebäude
|
||
</span>
|
||
</label>
|
||
<div className="form-text ms-4">
|
||
Wird bei der späteren Leitungsauslegung berücksichtigt,
|
||
insbesondere bei der Auswahl halogenfreier Kabel und
|
||
Leitungen.
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label" htmlFor="single-phase-voltage">
|
||
Standardspannung 1-phasig [V]
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="single-phase-voltage"
|
||
min="1"
|
||
onChange={(event) =>
|
||
setSinglePhaseVoltageV(event.target.value)
|
||
}
|
||
required
|
||
type="number"
|
||
value={singlePhaseVoltageV}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label" htmlFor="three-phase-voltage">
|
||
Standardspannung 3-phasig [V]
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="three-phase-voltage"
|
||
min="1"
|
||
onChange={(event) =>
|
||
setThreePhaseVoltageV(event.target.value)
|
||
}
|
||
required
|
||
type="number"
|
||
value={threePhaseVoltageV}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<hr className="my-2" />
|
||
<h3 className="h6">Deckblatt</h3>
|
||
<p className="text-secondary small">
|
||
Angaben für das Deckblatt der Seitenansicht. Büroname und
|
||
Büroanschrift werden projektübergreifend in der Seitenansicht
|
||
gepflegt.
|
||
</p>
|
||
<div className="row g-3">
|
||
{projectCoverFields.map((field) => (
|
||
<div
|
||
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
|
||
key={field}
|
||
>
|
||
<label className="form-label" htmlFor={`project-cover-${field}`}>
|
||
{projectCoverFieldLabels[field]}
|
||
</label>
|
||
{field === "siteAddress" ? (
|
||
<textarea
|
||
className="form-control"
|
||
id={`project-cover-${field}`}
|
||
maxLength={projectCoverFieldMaxLengths[field]}
|
||
onChange={(event) =>
|
||
setCoverValues((current) => ({
|
||
...current,
|
||
[field]: event.target.value,
|
||
}))
|
||
}
|
||
placeholder={coverFieldPlaceholders[field]}
|
||
rows={2}
|
||
value={coverValues[field]}
|
||
/>
|
||
) : (
|
||
<input
|
||
className="form-control"
|
||
id={`project-cover-${field}`}
|
||
maxLength={projectCoverFieldMaxLengths[field]}
|
||
onChange={(event) =>
|
||
setCoverValues((current) => ({
|
||
...current,
|
||
[field]: event.target.value,
|
||
}))
|
||
}
|
||
placeholder={coverFieldPlaceholders[field]}
|
||
value={coverValues[field]}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="col-12">
|
||
<hr className="my-2" />
|
||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||
<p className="text-secondary small">
|
||
Der Export enthält den vollständigen unterstützten
|
||
Projektzustand. Beim Duplizieren bleibt dieses Projekt
|
||
unverändert.
|
||
</p>
|
||
<button
|
||
className="btn btn-outline-primary mb-3"
|
||
disabled={isSaving}
|
||
onClick={() => void onExport()}
|
||
type="button"
|
||
>
|
||
Projektdatei herunterladen
|
||
</button>
|
||
<label className="form-label d-block" htmlFor="project-import">
|
||
Projektdatei auswählen
|
||
</label>
|
||
<input
|
||
accept=".json,application/json"
|
||
className="form-control"
|
||
id="project-import"
|
||
onChange={(event) =>
|
||
void handleTransferFile(event.target.files?.[0])
|
||
}
|
||
type="file"
|
||
/>
|
||
{transferFilename ? (
|
||
<div className="form-text">{transferFilename} ist bereit.</div>
|
||
) : null}
|
||
{fileError ? (
|
||
<div className="text-danger small mt-1">{fileError}</div>
|
||
) : null}
|
||
<div className="d-flex flex-wrap gap-3 mt-3">
|
||
<label className="form-check">
|
||
<input
|
||
checked={importMode === "duplicate"}
|
||
className="form-check-input"
|
||
name="import-mode"
|
||
onChange={() => setImportMode("duplicate")}
|
||
type="radio"
|
||
/>
|
||
<span className="form-check-label">
|
||
Als neues Projekt duplizieren
|
||
</span>
|
||
</label>
|
||
<label className="form-check">
|
||
<input
|
||
checked={importMode === "replace"}
|
||
className="form-check-input"
|
||
name="import-mode"
|
||
onChange={() => setImportMode("replace")}
|
||
type="radio"
|
||
/>
|
||
<span className="form-check-label">
|
||
Dieses Projekt ersetzen
|
||
</span>
|
||
</label>
|
||
</div>
|
||
{importMode === "replace" ? (
|
||
<label className="form-check mt-2">
|
||
<input
|
||
checked={replaceConfirmed}
|
||
className="form-check-input"
|
||
onChange={(event) =>
|
||
setReplaceConfirmed(event.target.checked)
|
||
}
|
||
type="checkbox"
|
||
/>
|
||
<span className="form-check-label text-danger">
|
||
Ich bestätige, dass der aktuelle Projektstand ersetzt
|
||
wird.
|
||
</span>
|
||
</label>
|
||
) : null}
|
||
<button
|
||
className="btn btn-outline-danger mt-3"
|
||
disabled={
|
||
isSaving ||
|
||
transfer === null ||
|
||
(importMode === "replace" && !replaceConfirmed)
|
||
}
|
||
onClick={() =>
|
||
transfer === null
|
||
? undefined
|
||
: void onImport(transfer, importMode)
|
||
}
|
||
type="button"
|
||
>
|
||
Projektdatei importieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</FormModal>
|
||
);
|
||
}
|
||
|
||
function toNullableString(value: string) {
|
||
return value.trim() || null;
|
||
}
|