forked from jappel/leistungsbilanz-ts
Integrate rough estimate balance
This commit is contained in:
commit
573487666c
45 changed files with 9378 additions and 58 deletions
|
|
@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [
|
|||
// Separate project sub-routes (/projects/[id]/<segment>).
|
||||
const PROJECT_ROUTES = [
|
||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
||||
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
|
||||
] as const;
|
||||
|
||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
||||
|
|
|
|||
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { FormModal } from "../components/form-modal";
|
||||
import type {
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceDto,
|
||||
EstimateBalanceTemplate,
|
||||
} from "../types";
|
||||
import { estimateTextLimits } from "./estimate-balance-document";
|
||||
import { getTodayIsoDate } from "./estimate-balance-format";
|
||||
|
||||
export interface EstimateBalanceCreateDefaults {
|
||||
name?: string;
|
||||
workStage?: string | null;
|
||||
template?: EstimateBalanceTemplate;
|
||||
copyFromBalanceId?: string;
|
||||
}
|
||||
|
||||
interface EstimateBalanceCreateModalProps {
|
||||
balances: EstimateBalanceDto[];
|
||||
defaults: EstimateBalanceCreateDefaults;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (input: CreateEstimateBalanceInput) => void;
|
||||
}
|
||||
|
||||
/** "Neuer Stand": name, LPH, date and the starting content. */
|
||||
export function EstimateBalanceCreateModal({
|
||||
balances,
|
||||
defaults,
|
||||
isSaving,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: EstimateBalanceCreateModalProps) {
|
||||
const [name, setName] = useState(defaults.name ?? "");
|
||||
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
|
||||
const [dateIso, setDateIso] = useState(getTodayIsoDate());
|
||||
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
|
||||
defaults.template ?? "kg400"
|
||||
);
|
||||
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
|
||||
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
|
||||
);
|
||||
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || isCopyInvalid) {
|
||||
return;
|
||||
}
|
||||
onCreate({
|
||||
name: name.trim(),
|
||||
workStage: workStage.trim() || null,
|
||||
dateIso: dateIso || null,
|
||||
template,
|
||||
...(template === "copy" ? { copyFromBalanceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!name.trim() || isCopyInvalid}
|
||||
submitLabel="Stand anlegen"
|
||||
title="Neuer Stand"
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="estimate-create-name"
|
||||
maxLength={estimateTextLimits.balanceName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="z. B. Entwurfsplanung"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-create-work-stage">
|
||||
LPH
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-create-work-stage"
|
||||
maxLength={estimateTextLimits.workStage}
|
||||
onChange={(event) => setWorkStage(event.target.value)}
|
||||
placeholder="z. B. LPH 3"
|
||||
value={workStage}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-create-date">
|
||||
Datum
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-create-date"
|
||||
onChange={(event) => setDateIso(event.target.value)}
|
||||
type="date"
|
||||
value={dateIso}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-template">
|
||||
Inhalt
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="estimate-create-template"
|
||||
onChange={(event) =>
|
||||
setTemplate(event.target.value as EstimateBalanceTemplate)
|
||||
}
|
||||
value={template}
|
||||
>
|
||||
<option value="kg400">Vorlage KG 400</option>
|
||||
<option value="empty">Leer</option>
|
||||
<option disabled={!balances.length} value="copy">
|
||||
Kopie von …
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{template === "copy" ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-copy-from">
|
||||
Kopie von Stand
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="estimate-create-copy-from"
|
||||
onChange={(event) => setCopyFromBalanceId(event.target.value)}
|
||||
required
|
||||
value={copyFromBalanceId}
|
||||
>
|
||||
{balances.map((balance) => (
|
||||
<option key={balance.id} value={balance.id}>
|
||||
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12">
|
||||
<p className="text-secondary small mb-0">
|
||||
{template === "kg400"
|
||||
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
|
||||
: template === "empty"
|
||||
? "Legt einen Stand ohne Kostengruppen an."
|
||||
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||
// The editor keeps the complete document as local draft and saves it as a
|
||||
// whole (PUT), so every edit is a function draft → new draft.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateGroupSnapshot,
|
||||
EstimateRowSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
|
||||
export type MoveDirection = -1 | 1;
|
||||
|
||||
/** Maximum text lengths accepted by the server. */
|
||||
export const estimateTextLimits = {
|
||||
balanceName: 200,
|
||||
workStage: 50,
|
||||
costGroup: 20,
|
||||
groupTitle: 200,
|
||||
groupEditor: 100,
|
||||
supply: 50,
|
||||
rowName: 200,
|
||||
remark: 500,
|
||||
} as const;
|
||||
|
||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||
|
||||
export function createEstimateRow(
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
sortOrder,
|
||||
supply: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateGroup(
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateGroupSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
costGroup: input.costGroup,
|
||||
title: input.title,
|
||||
editor: input.editor ?? null,
|
||||
sortOrder,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
|
||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||
export function normalizeEstimateSortOrder(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||
...row,
|
||||
sortOrder: rowIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||
return items.map((item, index) =>
|
||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||
);
|
||||
}
|
||||
|
||||
function mapGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
let changed = false;
|
||||
const groups = balance.groups.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
const next = update(group);
|
||||
changed = next !== group;
|
||||
return next;
|
||||
});
|
||||
// An unknown group or a no-op keeps the identical document.
|
||||
return changed ? { ...balance, groups } : balance;
|
||||
}
|
||||
|
||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||
export function addEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
options: { afterRowId?: string; createId?: () => string } = {}
|
||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||
let rowId: string | null = null;
|
||||
const next = mapGroup(balance, groupId, (group) => {
|
||||
const row = createEstimateRow(0, options.createId);
|
||||
rowId = row.id;
|
||||
const afterIndex = options.afterRowId
|
||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||
: -1;
|
||||
const rows = [...group.rows];
|
||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||
return { ...group, rows: renumber(rows) };
|
||||
});
|
||||
return { balance: next, rowId };
|
||||
}
|
||||
|
||||
export function updateEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
patch: EstimateRowPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function moveEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => {
|
||||
const rows = moveItem(
|
||||
group.rows,
|
||||
group.rows.findIndex((row) => row.id === rowId),
|
||||
direction
|
||||
);
|
||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a group and returns its id. */
|
||||
export function addEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
createId: () => string = createClientId
|
||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||
return {
|
||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||
groupId: group.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
patch: EstimateGroupPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||
}
|
||||
|
||||
export function removeEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||
return groups.length === balance.groups.length
|
||||
? balance
|
||||
: { ...balance, groups: renumber(groups) };
|
||||
}
|
||||
|
||||
export function moveEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = moveItem(
|
||||
balance.groups,
|
||||
balance.groups.findIndex((group) => group.id === groupId),
|
||||
direction
|
||||
);
|
||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||
}
|
||||
|
||||
function requiredText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact document the server accepts: strings trimmed, empty optional
|
||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||
* type are copied, so no UI state can leak into the request.
|
||||
*/
|
||||
export function toSaveableEstimateBalance(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
return {
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: requiredText(normalized.name),
|
||||
workStage: optionalText(normalized.workStage),
|
||||
dateIso: normalized.dateIso || null,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: requiredText(group.costGroup),
|
||||
title: requiredText(group.title),
|
||||
editor: optionalText(group.editor),
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: optionalText(row.supply),
|
||||
name: requiredText(row.name),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: optionalText(row.remark),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable comparison key of the saveable document (dirty detection). */
|
||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||
}
|
||||
|
||||
/**
|
||||
* German problems that would make the server reject the document. The
|
||||
* editor never saves while this list is not empty.
|
||||
*/
|
||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||
const problems: string[] = [];
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
if (!saveable.name) {
|
||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||
}
|
||||
for (const group of saveable.groups) {
|
||||
if (!group.title) {
|
||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function createCopyName(name: string) {
|
||||
const copyName = `${name.trim()} (Kopie)`;
|
||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||
}
|
||||
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
|
||||
// Numbers are entered and shown in German notation. As in the circuit list
|
||||
// editor, only "," is accepted as decimal separator: a "." is ambiguous
|
||||
// (decimal point or thousands separator) and is rejected instead of guessed.
|
||||
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
|
||||
|
||||
export type DecimalParseResult =
|
||||
| { ok: true; value: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface DecimalRange {
|
||||
min?: number;
|
||||
/** Excludes `min` itself (e.g. cos φ > 0). */
|
||||
minExclusive?: boolean;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
|
||||
|
||||
function formatBound(value: number) {
|
||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
|
||||
}
|
||||
|
||||
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Bitte einen Wert eingeben." };
|
||||
}
|
||||
if (trimmed.includes(".")) {
|
||||
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
|
||||
}
|
||||
if (!DECIMAL_PATTERN.test(trimmed)) {
|
||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||
}
|
||||
const value = Number(trimmed.replace(",", "."));
|
||||
if (!Number.isFinite(value)) {
|
||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||
}
|
||||
if (range.min !== undefined) {
|
||||
if (range.minExclusive ? value <= range.min : value < range.min) {
|
||||
return {
|
||||
ok: false,
|
||||
error: range.minExclusive
|
||||
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
|
||||
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (range.max !== undefined && value > range.max) {
|
||||
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Value ranges accepted by the server for the numeric document fields. */
|
||||
export const estimateNumberRanges = {
|
||||
quantity: { min: 0 },
|
||||
powerPerUnitW: { min: 0 },
|
||||
simultaneityFactor: { min: 0, max: 1 },
|
||||
cosPhi: { min: 0, minExclusive: true, max: 1 },
|
||||
reservePercent: { min: 0, max: 100 },
|
||||
} satisfies Record<string, DecimalRange>;
|
||||
|
||||
/**
|
||||
* Text of a numeric input field: German comma, no thousands separator, so the
|
||||
* shown text can be parsed again unchanged.
|
||||
*/
|
||||
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
|
||||
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only display of a calculated value with thousands separator ("1.200",
|
||||
* "30,24"). The value is rounded half away from zero first, like Excel does
|
||||
* (40,175 → 40,18 instead of the binary-rounded 40,17).
|
||||
*/
|
||||
export function formatGermanNumber(
|
||||
value: number,
|
||||
minimumFractionDigits: number,
|
||||
maximumFractionDigits = minimumFractionDigits
|
||||
): string {
|
||||
const rounded = roundEstimateValue(value, maximumFractionDigits);
|
||||
// "+ 0" turns -0 into 0 and avoids "-0,00".
|
||||
return (rounded + 0).toLocaleString("de-DE", {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatKw(value: number) {
|
||||
return formatGermanNumber(value, 2);
|
||||
}
|
||||
|
||||
export function formatAmpere(value: number) {
|
||||
return formatGermanNumber(value, 2);
|
||||
}
|
||||
|
||||
export function formatSignedKw(value: number) {
|
||||
const text = formatKw(value);
|
||||
return value > 0 && text !== "0,00" ? `+${text}` : text;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(value: number | null) {
|
||||
if (value === null) {
|
||||
return "–";
|
||||
}
|
||||
const text = formatGermanNumber(value, 1);
|
||||
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
|
||||
}
|
||||
|
||||
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
|
||||
export function formatIsoDateGerman(dateIso: string | null): string {
|
||||
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
|
||||
}
|
||||
|
||||
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
|
||||
export function getTodayIsoDate(now: Date = new Date()): string {
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
estimateRowConnectionLabels,
|
||||
estimateRowUnitLabels,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
type EstimatePower,
|
||||
type EstimateRowConnection,
|
||||
type EstimateRowSnapshot,
|
||||
type EstimateRowUnit,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import {
|
||||
addEstimateRow,
|
||||
estimateTextLimits,
|
||||
moveEstimateGroup,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
type EstimateRowPatch,
|
||||
} from "./estimate-balance-document";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatKw,
|
||||
parseGermanDecimal,
|
||||
type DecimalRange,
|
||||
formatGermanNumber,
|
||||
} from "./estimate-balance-format";
|
||||
import { DraftInput } from "./estimate-draft-input";
|
||||
|
||||
export type EstimateBalanceUpdate = (
|
||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
||||
) => void;
|
||||
|
||||
interface EstimateBalanceSheetProps {
|
||||
balance: EstimateBalanceSnapshot;
|
||||
disabled: boolean;
|
||||
onAddGroup: () => void;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
// Leading columns up to cos φ; the power columns follow.
|
||||
const LEADING_COLUMN_COUNT = 9;
|
||||
const COLUMN_COUNT = 16;
|
||||
|
||||
/** Suggested supply values; free text stays possible. */
|
||||
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
|
||||
const SUPPLY_LIST_ID = "estimate-supply-options";
|
||||
|
||||
function NumberInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
fieldKey,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
range,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
fieldKey: string;
|
||||
onCommit: (value: number) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
range: DecimalRange;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<DraftInput
|
||||
ariaLabel={ariaLabel}
|
||||
className="estimate-cell-input is-numeric"
|
||||
disabled={disabled}
|
||||
format={(current: number) => formatDecimalInput(current)}
|
||||
inputMode="decimal"
|
||||
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
|
||||
onCommit={onCommit}
|
||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
||||
parse={(text) => parseGermanDecimal(text, range)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
|
||||
const used = balance.groups.flatMap((group) =>
|
||||
group.rows.map((row) => row.supply?.trim() ?? "")
|
||||
);
|
||||
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
list,
|
||||
maxLength,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
list?: string;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
list={list}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{value ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "secondary" | "danger" | "primary";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupLabel(group: EstimateGroupSnapshot) {
|
||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function EstimateRowLine({
|
||||
disabled,
|
||||
group,
|
||||
onChange,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
row,
|
||||
rowIndex,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
group: EstimateGroupSnapshot;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
row: EstimateRowSnapshot;
|
||||
rowIndex: number;
|
||||
}) {
|
||||
const result = calculateEstimateRow(row);
|
||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
||||
|
||||
function patch(rowPatch: EstimateRowPatch) {
|
||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="estimate-row">
|
||||
<td className="estimate-cost-group-cell" />
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Versorgung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
list={SUPPLY_LIST_ID}
|
||||
maxLength={estimateTextLimits.supply}
|
||||
onChange={(supply) => patch({ supply })}
|
||||
placeholder="ELT"
|
||||
value={row.supply}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-name-cell">
|
||||
<TextInput
|
||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.rowName}
|
||||
onChange={(name) => patch({ name })}
|
||||
placeholder="Verbraucher oder Fläche"
|
||||
value={row.name}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("quantity")}
|
||||
onCommit={(quantity) => patch({ quantity })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.quantity}
|
||||
value={row.quantity}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Einheit (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
||||
value={row.unit}
|
||||
>
|
||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{estimateRowUnitLabels[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Anschluss (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
||||
})
|
||||
}
|
||||
value={row.connection ?? ""}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
||||
(connection) => (
|
||||
<option key={connection} value={connection}>
|
||||
{estimateRowConnectionLabels[connection]}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<span className="estimate-print-value">
|
||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("powerPerUnitW")}
|
||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.powerPerUnitW}
|
||||
value={row.powerPerUnitW}
|
||||
/>
|
||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-gzf-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("simultaneityFactor")}
|
||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.simultaneityFactor}
|
||||
value={row.simultaneityFactor}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
||||
className="estimate-amev-button estimate-no-print"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
type="button"
|
||||
>
|
||||
AMEV
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`cos φ (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("cosPhi")}
|
||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.cosPhi}
|
||||
value={row.cosPhi}
|
||||
/>
|
||||
</td>
|
||||
<PowerCells power={result.av} />
|
||||
<td className="estimate-center">
|
||||
<input
|
||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
||||
checked={row.safetySupply}
|
||||
className="form-check-input estimate-no-print"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
||||
</td>
|
||||
<PowerCells
|
||||
className={row.safetySupply ? "" : "is-muted"}
|
||||
power={result.sv}
|
||||
/>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.remark}
|
||||
onChange={(remark) => patch({ remark })}
|
||||
value={row.remark}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile löschen (${rowLabel})`}
|
||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreadsheet-like editor in the layout of the office template
|
||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
||||
* subtotal row, totals, reserve and maximum current at the end.
|
||||
*/
|
||||
export function EstimateBalanceSheet({
|
||||
balance,
|
||||
disabled,
|
||||
onAddGroup,
|
||||
onChange,
|
||||
onEditGroup,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
threePhaseVoltageV,
|
||||
}: EstimateBalanceSheetProps) {
|
||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
||||
|
||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
||||
const rowText =
|
||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
||||
if (
|
||||
window.confirm(
|
||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
onChange((current) => removeEstimateGroup(current, group.id));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<datalist id={SUPPLY_LIST_ID}>
|
||||
{supplyOptions(balance).map((supply) => (
|
||||
<option key={supply} value={supply} />
|
||||
))}
|
||||
</datalist>
|
||||
<table className="estimate-sheet">
|
||||
<colgroup>
|
||||
<col className="estimate-col-kg" />
|
||||
<col className="estimate-col-supply" />
|
||||
<col className="estimate-col-name" />
|
||||
<col className="estimate-col-quantity" />
|
||||
<col className="estimate-col-unit" />
|
||||
<col className="estimate-col-connection" />
|
||||
<col className="estimate-col-power" />
|
||||
<col className="estimate-col-gzf" />
|
||||
<col className="estimate-col-cosphi" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-sv" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-remark" />
|
||||
<col className="estimate-col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2}>KG</th>
|
||||
<th rowSpan={2}>
|
||||
Versorgung
|
||||
<span className="estimate-th-hint">ELT/MSR …</span>
|
||||
</th>
|
||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
||||
<th colSpan={2}>
|
||||
Anzahl / Fläche
|
||||
<span className="estimate-th-hint">St. / m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Anschl.
|
||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Einzelleist.
|
||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>GL-Faktor</th>
|
||||
<th rowSpan={2}>cos φ</th>
|
||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
||||
<th rowSpan={2}>Anmerkungen</th>
|
||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Wert</th>
|
||||
<th>Einh.</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
<th>ja/nein</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{balance.groups.map((group, groupIndex) => {
|
||||
const groupResult = result.groups[groupIndex]!;
|
||||
const label = groupLabel(group) || "Kostengruppe";
|
||||
return (
|
||||
<tbody className="estimate-group" key={group.id}>
|
||||
<tr className="estimate-group-title-row">
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td colSpan={COLUMN_COUNT - 2}>
|
||||
<div className="estimate-group-title">
|
||||
<span className="estimate-group-name">{group.title}</span>
|
||||
<label
|
||||
className={
|
||||
group.editor?.trim()
|
||||
? "estimate-group-editor"
|
||||
: "estimate-group-editor is-empty"
|
||||
}
|
||||
>
|
||||
<span>bearbeitet von</span>
|
||||
<input
|
||||
aria-label={`Bearbeitet von (${label})`}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) =>
|
||||
onChange((current) =>
|
||||
updateEstimateGroup(current, group.id, {
|
||||
editor: event.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder="Gewerk / Name"
|
||||
type="text"
|
||||
value={group.editor ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<div className="estimate-group-actions">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile hinzufügen (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
<Plus aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe bearbeiten (${label})`}
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === 0}
|
||||
label={`Gruppe nach oben (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
||||
}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
||||
label={`Gruppe nach unten (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
||||
}
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe löschen (${label})`}
|
||||
onClick={() => confirmRemoveGroup(group)}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<EstimateRowLine
|
||||
disabled={disabled}
|
||||
group={group}
|
||||
key={row.id}
|
||||
onChange={onChange}
|
||||
onOpenGzfPicker={onOpenGzfPicker}
|
||||
onValidityChange={onValidityChange}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
))}
|
||||
{!group.rows.length ? (
|
||||
<tr className="estimate-empty-row estimate-no-print">
|
||||
<td />
|
||||
<td colSpan={COLUMN_COUNT - 1}>
|
||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr className="estimate-subtotal-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Summe {label}
|
||||
</td>
|
||||
<PowerCells power={groupResult.av} />
|
||||
<td />
|
||||
<PowerCells power={groupResult.sv} />
|
||||
<td />
|
||||
<td className="estimate-no-print" />
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
})}
|
||||
<tfoot>
|
||||
<tr className="estimate-no-print estimate-add-group-row">
|
||||
<td colSpan={COLUMN_COUNT}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={disabled}
|
||||
onClick={onAddGroup}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
||||
Kostengruppe hinzufügen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Gesamtsumme
|
||||
</td>
|
||||
<PowerCells power={result.total.av} />
|
||||
<td />
|
||||
<PowerCells power={result.total.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
||||
Reserveleistung
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve AV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveAvPercent"
|
||||
onCommit={(reserveAvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveAvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.av} />
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve SV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveSvPercent"
|
||||
onCommit={(reserveSvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveSvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Leistung inkl. Reserve
|
||||
</td>
|
||||
<PowerCells power={result.withReserve.av} />
|
||||
<td />
|
||||
<PowerCells power={result.withReserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Maximalstrom
|
||||
<span className="estimate-total-hint">
|
||||
{threePhaseVoltageV > 0
|
||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
||||
: " (Drehstromspannung im Projekt fehlt)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.av)} A
|
||||
</td>
|
||||
<td />
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.sv)} A
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
roundEstimateValue,
|
||||
type EstimateComparison,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
|
||||
|
||||
function deltaClass(deltaKw: number) {
|
||||
if (roundEstimateValue(deltaKw, 2) === 0) {
|
||||
return "estimate-number";
|
||||
}
|
||||
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
|
||||
}
|
||||
|
||||
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
|
||||
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<table className="estimate-sheet estimate-comparison">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>KG</th>
|
||||
<th>Titel</th>
|
||||
<th>geschätzt AV kW</th>
|
||||
<th>detailliert kW</th>
|
||||
<th>Δ kW</th>
|
||||
<th>Δ %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.groups.map((group) => (
|
||||
<tr key={group.groupId}>
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td>
|
||||
{group.title}
|
||||
{group.detailedRowCount ? (
|
||||
<span className="estimate-th-hint">
|
||||
{group.detailedRowCount === 1
|
||||
? "1 Gerätezeile"
|
||||
: `${group.detailedRowCount} Gerätezeilen`}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
|
||||
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
|
||||
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
|
||||
<td className={deltaClass(group.deltaKw)}>
|
||||
{formatDeltaPercent(group.deltaPercent)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="estimate-unassigned-row">
|
||||
<td />
|
||||
<td>
|
||||
ohne Kostengruppe / nicht zugeordnet
|
||||
{comparison.unassigned.rowCount ? (
|
||||
<span className="estimate-th-hint">
|
||||
{comparison.unassigned.rowCount === 1
|
||||
? "1 Gerätezeile"
|
||||
: `${comparison.unassigned.rowCount} Gerätezeilen`}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="estimate-number">–</td>
|
||||
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
|
||||
<td className="estimate-number">–</td>
|
||||
<td className="estimate-number">–</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td />
|
||||
<td className="estimate-total-label">Summe</td>
|
||||
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
|
||||
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
|
||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||
{formatSignedKw(comparison.total.deltaKw)}
|
||||
</td>
|
||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||
{formatDeltaPercent(comparison.total.deltaPercent)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
interface DraftInputProps<T> {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
format: (value: T) => string;
|
||||
/** Printed text; defaults to the input text. */
|
||||
printFormat?: (value: T) => string;
|
||||
inputMode?: "decimal" | "text";
|
||||
maxLength?: number;
|
||||
onCommit: (value: T) => void;
|
||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
parse: (text: string) => DraftParseResult<T>;
|
||||
placeholder?: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that keeps the raw text the user typed. Only a valid text is
|
||||
* committed into the document; an invalid text stays visible with a red
|
||||
* border and is never saved. A value changed from outside (reload, undo)
|
||||
* replaces the text unless the text already represents that value.
|
||||
*/
|
||||
export function DraftInput<T>({
|
||||
ariaLabel,
|
||||
className = "estimate-cell-input",
|
||||
disabled,
|
||||
format,
|
||||
printFormat,
|
||||
inputMode = "text",
|
||||
maxLength,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
parse,
|
||||
placeholder,
|
||||
value,
|
||||
}: DraftInputProps<T>) {
|
||||
const [text, setText] = useState(() => format(value));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncedValue, setSyncedValue] = useState(value);
|
||||
|
||||
if (!Object.is(syncedValue, value)) {
|
||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
||||
// with stale text after an external change.
|
||||
setSyncedValue(value);
|
||||
const parsed = parse(text);
|
||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
||||
setText(format(value));
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
const validityRef = useRef(onValidityChange);
|
||||
useEffect(() => {
|
||||
validityRef.current = onValidityChange;
|
||||
});
|
||||
const isValid = error === null;
|
||||
useEffect(() => {
|
||||
validityRef.current?.(isValid);
|
||||
}, [isValid]);
|
||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
||||
useEffect(() => () => validityRef.current?.(true), []);
|
||||
|
||||
function handleChange(nextText: string) {
|
||||
setText(nextText);
|
||||
const parsed = parse(nextText);
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!Object.is(parsed.value, value)) {
|
||||
setSyncedValue(parsed.value);
|
||||
onCommit(parsed.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
||||
if (error === null) {
|
||||
setText(format(value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-label={ariaLabel}
|
||||
className={error ? `${className} is-invalid` : className}
|
||||
disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
title={error ?? undefined}
|
||||
type="text"
|
||||
value={text}
|
||||
/>
|
||||
<span className="estimate-print-value">
|
||||
{printFormat ? printFormat(value) : error ? format(value) : text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { FormModal } from "../components/form-modal";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { estimateTextLimits } from "./estimate-balance-document";
|
||||
|
||||
export interface EstimateGroupFormValues {
|
||||
costGroup: string;
|
||||
title: string;
|
||||
editor: string | null;
|
||||
}
|
||||
|
||||
interface EstimateGroupModalProps {
|
||||
group: EstimateGroupSnapshot | null;
|
||||
onClose: () => void;
|
||||
onSave: (values: EstimateGroupFormValues) => void;
|
||||
}
|
||||
|
||||
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
|
||||
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
|
||||
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
|
||||
const [title, setTitle] = useState(group?.title ?? "");
|
||||
const [editor, setEditor] = useState(group?.editor ?? "");
|
||||
|
||||
function applyTemplate(index: string) {
|
||||
const template = estimateDefaultGroups[Number(index)];
|
||||
if (template) {
|
||||
setCostGroup(template.costGroup);
|
||||
setTitle(template.title);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
costGroup: costGroup.trim(),
|
||||
title: title.trim(),
|
||||
editor: editor.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isSaving={false}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!title.trim()}
|
||||
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
|
||||
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
{!group ? (
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="estimate-group-template">
|
||||
Aus Vorlage KG 400 übernehmen
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
defaultValue=""
|
||||
id="estimate-group-template"
|
||||
onChange={(event) => applyTemplate(event.target.value)}
|
||||
>
|
||||
<option value="">– Vorlage wählen –</option>
|
||||
{estimateDefaultGroups.map((template, index) => (
|
||||
<option key={`${template.costGroup}-${template.title}`} value={index}>
|
||||
{[template.costGroup, template.title].filter(Boolean).join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-group-cost-group">
|
||||
KG (DIN 276)
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-cost-group"
|
||||
maxLength={estimateTextLimits.costGroup}
|
||||
onChange={(event) => setCostGroup(event.target.value)}
|
||||
placeholder="z. B. 440"
|
||||
value={costGroup}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-9">
|
||||
<label className="form-label" htmlFor="estimate-group-title">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-title"
|
||||
maxLength={estimateTextLimits.groupTitle}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="z. B. Starkstromanlagen"
|
||||
required
|
||||
value={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="estimate-group-editor">
|
||||
Bearbeitet von
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-editor"
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) => setEditor(event.target.value)}
|
||||
placeholder="Gewerk oder Name (optional)"
|
||||
value={editor}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<p className="text-secondary small mb-0">
|
||||
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
|
||||
detaillierten Bilanz keiner Kostengruppe zugeordnet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
|
||||
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
|
||||
import { formatIsoDateGerman } from "./estimate-balance-format";
|
||||
|
||||
interface EstimatePrintHeaderProps {
|
||||
project: ProjectDto;
|
||||
balance: EstimateBalanceDto;
|
||||
}
|
||||
|
||||
function cssString(value: string) {
|
||||
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print-only title block and page footer of the rough power balance in the
|
||||
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
|
||||
* meta data, "Seite x von y". Hidden on screen.
|
||||
*/
|
||||
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
|
||||
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
|
||||
const [hasLogo, setHasLogo] = useState(false);
|
||||
const [logoVersion, setLogoVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getDocumentBranding()
|
||||
.then((branding) => {
|
||||
setOffice(branding.office);
|
||||
setHasLogo(Boolean(branding.logo));
|
||||
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
|
||||
})
|
||||
.catch(() => {
|
||||
setOffice(null);
|
||||
setHasLogo(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
|
||||
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
|
||||
const footer = `
|
||||
@page estimate-balance {
|
||||
size: A4 landscape;
|
||||
margin: 10mm 10mm 13mm;
|
||||
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{footer}</style>
|
||||
<header className="estimate-print-titleblock" aria-hidden="true">
|
||||
<div className="estimate-print-logo">
|
||||
{hasLogo ? (
|
||||
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
|
||||
) : (
|
||||
<span>{office?.officeName ?? ""}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="estimate-print-title">
|
||||
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
|
||||
<div className="estimate-print-heading">
|
||||
<span className="estimate-print-stand">{balance.name}</span>
|
||||
<span className="estimate-print-project">{project.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="estimate-print-meta">
|
||||
<div>
|
||||
<dt>Projekt-Nr.</dt>
|
||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bauherr</dt>
|
||||
<dd>{project.buildingOwner ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Anschrift</dt>
|
||||
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl className="estimate-print-meta is-tinted">
|
||||
<div>
|
||||
<dt>Leistungsphase</dt>
|
||||
<dd>{balance.workStage ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datum</dt>
|
||||
<dd>{date ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bearbeitung</dt>
|
||||
<dd>{project.projectEditor ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Büro</dt>
|
||||
<dd>{office?.officeName ?? "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto {
|
|||
}
|
||||
|
||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||
|
||||
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
|
||||
// The document shapes are shared with the API; see
|
||||
// src/shared/estimate-balance/estimate-balance.ts.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateComparison,
|
||||
} from "../shared/estimate-balance/estimate-balance";
|
||||
|
||||
export type EstimateBalanceDto = EstimateBalanceSnapshot;
|
||||
export type EstimateComparisonDto = EstimateComparison;
|
||||
|
||||
export type EstimateBalanceTemplate = "kg400" | "empty" | "copy";
|
||||
|
||||
export interface CreateEstimateBalanceInput {
|
||||
name: string;
|
||||
workStage?: string | null;
|
||||
dateIso?: string | null;
|
||||
template: EstimateBalanceTemplate;
|
||||
/** Required for template "copy". */
|
||||
copyFromBalanceId?: string;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto {
|
||||
balance: EstimateBalanceDto;
|
||||
}
|
||||
|
||||
/** PUT result: either a new revision or an unchanged document. */
|
||||
export type EstimateBalanceUpdateResultDto =
|
||||
| EstimateBalanceCommandResultDto
|
||||
| { unchanged: true; balance: EstimateBalanceDto };
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import type {
|
|||
ExternalInitialImportPlanDto,
|
||||
ApplyExternalInitialImportInput,
|
||||
ExternalInitialImportResultDto,
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceCommandResultDto,
|
||||
EstimateBalanceDto,
|
||||
EstimateBalanceUpdateResultDto,
|
||||
EstimateComparisonDto,
|
||||
} from "../types";
|
||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||
|
|
@ -87,6 +92,30 @@ import type {
|
|||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
|
||||
/**
|
||||
* Error of a failed API request. The message stays the server's German
|
||||
* error text; status and optional error code allow callers to react to
|
||||
* specific failures such as a revision conflict (409).
|
||||
*/
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | null;
|
||||
|
||||
constructor(message: string, status: number, code: string | null = null) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRevisionConflictError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT")
|
||||
);
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
|
|
@ -99,15 +128,23 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|||
|
||||
if (!response.ok) {
|
||||
let details = await response.text();
|
||||
let code: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(details) as { error?: unknown };
|
||||
const parsed = JSON.parse(details) as { error?: unknown; code?: unknown };
|
||||
if (typeof parsed.error === "string") {
|
||||
details = parsed.error;
|
||||
}
|
||||
if (typeof parsed.code === "string") {
|
||||
code = parsed.code;
|
||||
}
|
||||
} catch {
|
||||
// Keep non-JSON error responses unchanged.
|
||||
}
|
||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||
throw new ApiRequestError(
|
||||
details || `Anfrage fehlgeschlagen (Status ${response.status})`,
|
||||
response.status,
|
||||
code
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
|
|
@ -1135,3 +1172,66 @@ export function disconnectProjectDeviceRows(
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
|
||||
|
||||
function estimateBalancesUrl(projectId: string, balanceId?: string) {
|
||||
const base = `/api/projects/${projectId}/estimate-balances`;
|
||||
return balanceId ? `${base}/${balanceId}` : base;
|
||||
}
|
||||
|
||||
export function listEstimateBalances(projectId: string) {
|
||||
return request<EstimateBalanceDto[]>(estimateBalancesUrl(projectId));
|
||||
}
|
||||
|
||||
export function getEstimateBalance(projectId: string, balanceId: string) {
|
||||
return request<EstimateBalanceDto>(estimateBalancesUrl(projectId, balanceId));
|
||||
}
|
||||
|
||||
export function createEstimateBalance(
|
||||
projectId: string,
|
||||
input: CreateEstimateBalanceInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<EstimateBalanceCommandResultDto>(estimateBalancesUrl(projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the complete target document. `keepalive` lets the request outlive
|
||||
* the page, e.g. for the last autosave while the tab is being closed.
|
||||
*/
|
||||
export function updateEstimateBalance(
|
||||
projectId: string,
|
||||
balance: EstimateBalanceDto,
|
||||
expectedRevision: number,
|
||||
options: { keepalive?: boolean } = {}
|
||||
) {
|
||||
return request<EstimateBalanceUpdateResultDto>(
|
||||
estimateBalancesUrl(projectId, balance.id),
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ expectedRevision, balance }),
|
||||
...(options.keepalive ? { keepalive: true } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEstimateBalance(
|
||||
projectId: string,
|
||||
balanceId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectCommandResultDto>(estimateBalancesUrl(projectId, balanceId), {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getEstimateComparison(projectId: string, balanceId: string) {
|
||||
return request<EstimateComparisonDto>(
|
||||
`${estimateBalancesUrl(projectId, balanceId)}/comparison`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"external-object.unassign-and-delete-created-row":
|
||||
"Externes Objekt aus erzeugter Gerätezeile gelöst",
|
||||
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
|
||||
"estimate-balance.insert": "Grobe Leistungsbilanz angelegt",
|
||||
"estimate-balance.update": "Grobe Leistungsbilanz bearbeitet",
|
||||
"estimate-balance.delete": "Grobe Leistungsbilanz entfernt",
|
||||
};
|
||||
|
||||
export function getProjectRevisionSourceLabel(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue