leistungsbilanz-ts/src/frontend/components/project-settings-modal.tsx
Grovy311 7692699ee2 Add print cover fields and office profile
- 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>
2026-09-26 10:39:18 +02:00

489 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}