forked from jappel/leistungsbilanz-ts
Add optional room areas
This commit is contained in:
parent
a6500e2b26
commit
dce31ee143
26 changed files with 3669 additions and 78 deletions
|
|
@ -4,6 +4,10 @@ import React, { type FormEvent, useState } from "react";
|
|||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
||||
import { FormModal } from "./form-modal";
|
||||
import {
|
||||
formatOptionalRoomAreaInput,
|
||||
parseRoomAreaInput,
|
||||
} from "../utils/room-area";
|
||||
|
||||
interface RoomModalProps {
|
||||
/** When given, the floor select groups floors by building. */
|
||||
|
|
@ -36,14 +40,21 @@ export function RoomModal({
|
|||
const [floorId, setFloorId] = useState(
|
||||
initialRoom?.floorId ?? initialValues?.floorId ?? ""
|
||||
);
|
||||
const [areaText, setAreaText] = useState(() =>
|
||||
formatOptionalRoomAreaInput(
|
||||
initialRoom ? initialRoom.areaM2 : initialValues?.areaM2
|
||||
)
|
||||
);
|
||||
const parsedArea = parseRoomAreaInput(areaText);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!roomNumber.trim() || !roomName.trim()) return;
|
||||
if (!roomNumber.trim() || !roomName.trim() || !parsedArea.ok) return;
|
||||
await onSave({
|
||||
floorId: floorId || undefined,
|
||||
roomNumber: roomNumber.trim(),
|
||||
roomName: roomName.trim(),
|
||||
areaM2: parsedArea.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +63,7 @@ export function RoomModal({
|
|||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
||||
submitDisabled={!roomNumber.trim() || !roomName.trim() || !parsedArea.ok}
|
||||
submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"}
|
||||
title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
||||
>
|
||||
|
|
@ -90,6 +101,27 @@ export function RoomModal({
|
|||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="room-area">
|
||||
Fläche [m²]
|
||||
</label>
|
||||
<input
|
||||
aria-describedby="room-area-help"
|
||||
aria-invalid={!parsedArea.ok}
|
||||
className={`form-control${parsedArea.ok ? "" : " is-invalid"}`}
|
||||
id="room-area"
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setAreaText(event.target.value)}
|
||||
placeholder="z. B. 23,45"
|
||||
type="text"
|
||||
value={areaText}
|
||||
/>
|
||||
<div className={parsedArea.ok ? "form-text" : "invalid-feedback"} id="room-area-help">
|
||||
{parsedArea.ok
|
||||
? "Optionale Netto-Raumfläche; Eingabe mit Dezimalkomma."
|
||||
: parsedArea.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{initialRoom && onDelete ? (
|
||||
<div className="border-top mt-4 pt-3">
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ export interface RoomDto {
|
|||
floorId: string | null;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
areaM2: number | null;
|
||||
}
|
||||
|
||||
export interface GlobalDeviceDto {
|
||||
|
|
@ -315,6 +316,7 @@ export interface CreateRoomInput {
|
|||
floorId?: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
areaM2: number | null;
|
||||
}
|
||||
|
||||
export interface CreateGlobalDeviceInput {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,19 @@ type FloorLike = Pick<FloorDto, "id" | "name"> & {
|
|||
buildingId?: string | null;
|
||||
};
|
||||
type RoomLike = Pick<RoomDto, "floorId">;
|
||||
type RoomAreaLike = Pick<RoomDto, "floorId" | "areaM2">;
|
||||
|
||||
export interface RoomAreaSummary {
|
||||
areaM2: number;
|
||||
roomCount: number;
|
||||
missingAreaCount: number;
|
||||
}
|
||||
|
||||
export interface StructureAreaSummaries {
|
||||
total: RoomAreaSummary;
|
||||
byBuilding: Map<string | null, RoomAreaSummary>;
|
||||
byFloor: Map<string | null, RoomAreaSummary>;
|
||||
}
|
||||
|
||||
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
||||
/** null for floors without (known) building. */
|
||||
|
|
@ -262,6 +275,59 @@ export function countRoomsByFloor(
|
|||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums only known room areas and counts missing values separately. Rooms with
|
||||
* no known floor/building are grouped under null for the corresponding UI
|
||||
* entries "Ohne Etage" and "Ohne Gebäude".
|
||||
*/
|
||||
export function summarizeRoomAreas(
|
||||
rooms: ReadonlyArray<RoomAreaLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): StructureAreaSummaries {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const floorsById = new Map(floors.map((floor) => [floor.id, floor]));
|
||||
const byBuilding = new Map<string | null, RoomAreaSummary>();
|
||||
const byFloor = new Map<string | null, RoomAreaSummary>();
|
||||
const total = emptyAreaSummary();
|
||||
|
||||
for (const room of rooms) {
|
||||
const floor = room.floorId === null ? undefined : floorsById.get(room.floorId);
|
||||
const floorId = floor?.id ?? null;
|
||||
const buildingId = floor
|
||||
? resolveFloorBuildingId(floor, buildingIds)
|
||||
: null;
|
||||
addRoomArea(total, room.areaM2);
|
||||
addRoomArea(summaryFor(byFloor, floorId), room.areaM2);
|
||||
addRoomArea(summaryFor(byBuilding, buildingId), room.areaM2);
|
||||
}
|
||||
return { total, byBuilding, byFloor };
|
||||
}
|
||||
|
||||
function emptyAreaSummary(): RoomAreaSummary {
|
||||
return { areaM2: 0, roomCount: 0, missingAreaCount: 0 };
|
||||
}
|
||||
|
||||
function summaryFor(
|
||||
summaries: Map<string | null, RoomAreaSummary>,
|
||||
key: string | null
|
||||
) {
|
||||
const existing = summaries.get(key);
|
||||
if (existing) return existing;
|
||||
const created = emptyAreaSummary();
|
||||
summaries.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function addRoomArea(summary: RoomAreaSummary, areaM2: number | null) {
|
||||
summary.roomCount += 1;
|
||||
if (areaM2 === null) {
|
||||
summary.missingAreaCount += 1;
|
||||
} else {
|
||||
summary.areaM2 += areaM2;
|
||||
}
|
||||
}
|
||||
|
||||
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
|
||||
export function describeRoomLocation(
|
||||
room: RoomLike,
|
||||
|
|
|
|||
30
src/frontend/utils/room-area.ts
Normal file
30
src/frontend/utils/room-area.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type RoomAreaParseResult =
|
||||
| { ok: true; value: number | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Parses an optional German decimal without rounding the stored value. */
|
||||
export function parseRoomAreaInput(text: string): RoomAreaParseResult {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return { ok: true, value: null };
|
||||
if (!/^(?:\d+(?:,\d*)?|,\d+)$/.test(normalized)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Bitte eine nicht negative Zahl mit Komma eingeben.",
|
||||
};
|
||||
}
|
||||
const value = Number(normalized.replace(",", "."));
|
||||
return Number.isFinite(value) && value >= 0
|
||||
? { ok: true, value }
|
||||
: { ok: false, error: "Die Fläche muss mindestens 0 m² betragen." };
|
||||
}
|
||||
|
||||
export function formatRoomArea(areaM2: number): string {
|
||||
return areaM2.toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatOptionalRoomAreaInput(areaM2: number | null | undefined): string {
|
||||
return areaM2 === null || areaM2 === undefined ? "" : formatRoomArea(areaM2);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue