Add optional room areas
This commit is contained in:
parent
a6500e2b26
commit
dce31ee143
26 changed files with 3669 additions and 78 deletions
|
|
@ -48,13 +48,17 @@ import {
|
|||
filterRooms,
|
||||
groupFloorsByBuilding,
|
||||
hasFloorsWithoutBuilding,
|
||||
hasRoomsWithoutFloor,
|
||||
normalizeBuildingSelection,
|
||||
normalizeFloorSelection,
|
||||
showsRoomsWithoutFloorEntry,
|
||||
summarizeRoomAreas,
|
||||
type RoomAreaSummary,
|
||||
type StructureSelection,
|
||||
} from "../../../../frontend/utils/building-structure";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||
import { formatRoomArea } from "../../../../frontend/utils/room-area";
|
||||
|
||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||
|
|
@ -68,6 +72,23 @@ function formatCount(count: number, singular: string, plural: string) {
|
|||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function formatAreaSummary(summary: RoomAreaSummary) {
|
||||
return `${formatRoomArea(summary.areaM2)} m² · ${formatCount(
|
||||
summary.missingAreaCount,
|
||||
"Raum ohne Fläche",
|
||||
"Räume ohne Fläche"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function formatStructureMeta(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string,
|
||||
summary: RoomAreaSummary
|
||||
) {
|
||||
return `${formatCount(count, singular, plural)} · ${formatAreaSummary(summary)}`;
|
||||
}
|
||||
|
||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||
return (
|
||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||
|
|
@ -307,7 +328,25 @@ export default function BuildingStructurePage() {
|
|||
() => countRoomsByFloor(rooms, floors),
|
||||
[floors, rooms]
|
||||
);
|
||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
||||
const areaSummaries = useMemo(
|
||||
() => summarizeRoomAreas(rooms, floors, buildings),
|
||||
[buildings, floors, rooms]
|
||||
);
|
||||
const emptyAreaSummary: RoomAreaSummary = {
|
||||
areaM2: 0,
|
||||
roomCount: 0,
|
||||
missingAreaCount: 0,
|
||||
};
|
||||
const selectedBuildingAreaSummary =
|
||||
effectiveBuildingSelection === ALL_SELECTION
|
||||
? areaSummaries.total
|
||||
: areaSummaries.byBuilding.get(
|
||||
effectiveBuildingSelection === UNASSIGNED_SELECTION
|
||||
? null
|
||||
: effectiveBuildingSelection
|
||||
) ?? emptyAreaSummary;
|
||||
const showUnassignedBuilding =
|
||||
hasFloorsWithoutBuilding(floors, buildings) || hasRoomsWithoutFloor(rooms, floors);
|
||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||
rooms,
|
||||
floors,
|
||||
|
|
@ -435,7 +474,8 @@ export default function BuildingStructurePage() {
|
|||
editingRoom &&
|
||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||
editingRoom.roomName === input.roomName.trim() &&
|
||||
editingRoom.floorId === (input.floorId || null)
|
||||
editingRoom.floorId === (input.floorId || null) &&
|
||||
editingRoom.areaM2 === input.areaM2
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
|
|
@ -536,7 +576,12 @@ export default function BuildingStructurePage() {
|
|||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
||||
meta={formatStructureMeta(
|
||||
floors.length,
|
||||
"Etage",
|
||||
"Etagen",
|
||||
areaSummaries.total
|
||||
)}
|
||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||
/>
|
||||
{buildings.map((building) => (
|
||||
|
|
@ -553,10 +598,11 @@ export default function BuildingStructurePage() {
|
|||
isSelected={effectiveBuildingSelection === building.id}
|
||||
key={building.id}
|
||||
label={building.name}
|
||||
meta={formatCount(
|
||||
meta={formatStructureMeta(
|
||||
floorCountByBuilding.get(building.id) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
"Etagen",
|
||||
areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => selectBuilding(building.id)}
|
||||
/>
|
||||
|
|
@ -565,10 +611,11 @@ export default function BuildingStructurePage() {
|
|||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||
meta={formatCount(
|
||||
meta={formatStructureMeta(
|
||||
floorCountByBuilding.get(null) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
"Etagen",
|
||||
areaSummaries.byBuilding.get(null) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
|
|
@ -591,7 +638,12 @@ export default function BuildingStructurePage() {
|
|||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
||||
meta={formatStructureMeta(
|
||||
roomsInBuildingSelection,
|
||||
"Raum",
|
||||
"Räume",
|
||||
selectedBuildingAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||
/>
|
||||
{visibleFloorGroups.map((group) => (
|
||||
|
|
@ -613,10 +665,11 @@ export default function BuildingStructurePage() {
|
|||
isSelected={effectiveFloorSelection === floor.id}
|
||||
key={floor.id}
|
||||
label={floor.name}
|
||||
meta={formatCount(
|
||||
meta={formatStructureMeta(
|
||||
roomCountByFloor.get(floor.id) ?? 0,
|
||||
"Raum",
|
||||
"Räume"
|
||||
"Räume",
|
||||
areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(floor.id)}
|
||||
/>
|
||||
|
|
@ -627,7 +680,12 @@ export default function BuildingStructurePage() {
|
|||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
||||
meta={formatStructureMeta(
|
||||
roomCountByFloor.get(null) ?? 0,
|
||||
"Raum",
|
||||
"Räume",
|
||||
areaSummaries.byFloor.get(null) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -671,6 +729,11 @@ export default function BuildingStructurePage() {
|
|||
{room.roomName}
|
||||
</>
|
||||
}
|
||||
meta={
|
||||
room.areaM2 === null
|
||||
? "Fläche fehlt"
|
||||
: `${formatRoomArea(room.areaM2)} m²`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!visibleRooms.length && !isLoading ? (
|
||||
|
|
|
|||
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `rooms` ADD `area_m2` real;
|
||||
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -78,6 +78,13 @@
|
|||
"when": 1790438092269,
|
||||
"tag": "0010_estimate_balances",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1790531351492,
|
||||
"tag": "0011_glossy_norman_osborn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
projectRoomInsertCommandType,
|
||||
projectRoomUpdateCommandType,
|
||||
upgradeProjectFloorCommand,
|
||||
upgradeProjectRoomCommand,
|
||||
type ProjectBuildingSnapshot,
|
||||
type ProjectFloorSnapshot,
|
||||
type ProjectLocationStructureProjectCommand,
|
||||
|
|
@ -106,23 +107,29 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
const current = upgradeProjectFloorCommand(command);
|
||||
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
||||
}
|
||||
case projectRoomInsertCommandType:
|
||||
case projectRoomInsertCommandType: {
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
const current = upgradeProjectRoomCommand(command);
|
||||
return this.insertRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
current.payload.room
|
||||
);
|
||||
case projectRoomDeleteCommandType:
|
||||
}
|
||||
case projectRoomDeleteCommandType: {
|
||||
assertProjectRoomDeleteProjectCommand(command);
|
||||
const current = upgradeProjectRoomCommand(command);
|
||||
return this.deleteRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
current.payload.room
|
||||
);
|
||||
case projectRoomUpdateCommandType:
|
||||
}
|
||||
case projectRoomUpdateCommandType: {
|
||||
assertProjectRoomUpdateProjectCommand(command);
|
||||
return this.updateRoom(database, projectId, command.payload.expected, command.payload.target);
|
||||
const current = upgradeProjectRoomCommand(command);
|
||||
return this.updateRoom(database, projectId, current.payload.expected, current.payload.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,6 +400,7 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
floorId: target.floorId,
|
||||
roomNumber: target.roomNumber,
|
||||
roomName: target.roomName,
|
||||
areaM2: target.areaM2,
|
||||
})
|
||||
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
|
||||
.run();
|
||||
|
|
@ -474,6 +482,15 @@ function toCurrentCommandVersion(
|
|||
case projectFloorUpdateCommandType:
|
||||
assertProjectFloorUpdateProjectCommand(command);
|
||||
return upgradeProjectFloorCommand(command);
|
||||
case projectRoomInsertCommandType:
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
return upgradeProjectRoomCommand(command);
|
||||
case projectRoomDeleteCommandType:
|
||||
assertProjectRoomDeleteProjectCommand(command);
|
||||
return upgradeProjectRoomCommand(command);
|
||||
case projectRoomUpdateCommandType:
|
||||
assertProjectRoomUpdateProjectCommand(command);
|
||||
return upgradeProjectRoomCommand(command);
|
||||
default:
|
||||
return command;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { floors } from "./floors.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
|
|
@ -10,4 +10,6 @@ export const rooms = sqliteTable("rooms", {
|
|||
floorId: text("floor_id").references(() => floors.id, { onDelete: "set null" }),
|
||||
roomNumber: text("room_number").notNull(),
|
||||
roomName: text("room_name").notNull(),
|
||||
/** Optional net room area from the Revit room parameter "Fläche". */
|
||||
areaM2: real("area_m2"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const projectFloorUpdateCommandType = "project-floor.update" as const;
|
|||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
||||
/** Room commands, building commands and legacy floor commands. */
|
||||
/** Building commands and legacy floor/room commands. */
|
||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||
/**
|
||||
* Floor commands carry `buildingId` since version 2. Version 1 floor
|
||||
|
|
@ -19,6 +19,9 @@ export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
|||
*/
|
||||
export const projectFloorCommandSchemaVersion = 2 as const;
|
||||
const legacyProjectFloorCommandSchemaVersion = 1 as const;
|
||||
/** Room commands carry `areaM2` since version 2. */
|
||||
export const projectRoomCommandSchemaVersion = 2 as const;
|
||||
const legacyProjectRoomCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectBuildingSnapshot {
|
||||
id: string;
|
||||
|
|
@ -43,8 +46,11 @@ export interface ProjectRoomSnapshot {
|
|||
floorId: string | null;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
areaM2: number | null;
|
||||
}
|
||||
|
||||
export type LegacyProjectRoomSnapshot = Omit<ProjectRoomSnapshot, "areaM2">;
|
||||
|
||||
interface ProjectBuildingStructureCommandPayload {
|
||||
building: ProjectBuildingSnapshot;
|
||||
}
|
||||
|
|
@ -61,6 +67,10 @@ interface ProjectRoomStructureCommandPayload {
|
|||
room: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
interface LegacyProjectRoomStructureCommandPayload {
|
||||
room: LegacyProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectBuildingUpdateCommandPayload {
|
||||
expected: ProjectBuildingSnapshot;
|
||||
target: ProjectBuildingSnapshot;
|
||||
|
|
@ -81,6 +91,11 @@ interface ProjectRoomUpdateCommandPayload {
|
|||
target: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
interface LegacyProjectRoomUpdateCommandPayload {
|
||||
expected: LegacyProjectRoomSnapshot;
|
||||
target: LegacyProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
|
|
@ -137,19 +152,37 @@ export interface LegacyProjectFloorUpdateProjectCommand
|
|||
|
||||
export interface ProjectRoomInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectRoomInsertProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomInsertCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectRoomDeleteProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectRoomUpdateProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectRoomUpdateCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||
type: typeof projectRoomUpdateCommandType;
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +198,10 @@ export type ProjectLocationStructureProjectCommand =
|
|||
| LegacyProjectFloorUpdateProjectCommand
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| ProjectRoomUpdateProjectCommand;
|
||||
| ProjectRoomUpdateProjectCommand
|
||||
| LegacyProjectRoomInsertProjectCommand
|
||||
| LegacyProjectRoomDeleteProjectCommand
|
||||
| LegacyProjectRoomUpdateProjectCommand;
|
||||
|
||||
export function createProjectBuildingSnapshot(
|
||||
projectId: string,
|
||||
|
|
@ -205,6 +241,7 @@ export function createProjectRoomSnapshot(
|
|||
floorId?: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
areaM2?: number | null;
|
||||
}
|
||||
): ProjectRoomSnapshot {
|
||||
const room: ProjectRoomSnapshot = {
|
||||
|
|
@ -213,6 +250,7 @@ export function createProjectRoomSnapshot(
|
|||
floorId: input.floorId?.trim() || null,
|
||||
roomNumber: input.roomNumber.trim(),
|
||||
roomName: input.roomName.trim(),
|
||||
areaM2: input.areaM2 ?? null,
|
||||
};
|
||||
assertProjectRoomSnapshot(room);
|
||||
return room;
|
||||
|
|
@ -279,7 +317,7 @@ export function createProjectRoomInsertProjectCommand(
|
|||
): ProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectRoomCommandSchemaVersion,
|
||||
type: projectRoomInsertCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
|
|
@ -290,7 +328,7 @@ export function createProjectRoomDeleteProjectCommand(
|
|||
): ProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectRoomCommandSchemaVersion,
|
||||
type: projectRoomDeleteCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
|
|
@ -314,7 +352,7 @@ export function createProjectRoomUpdateProjectCommand(
|
|||
): ProjectRoomUpdateProjectCommand {
|
||||
assertMatchingRoomUpdate(expected, target);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectRoomCommandSchemaVersion,
|
||||
type: projectRoomUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
|
|
@ -376,7 +414,9 @@ export function assertProjectFloorDeleteProjectCommand(
|
|||
|
||||
export function assertProjectRoomInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomInsertProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| LegacyProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomInsertCommandType
|
||||
|
|
@ -385,7 +425,9 @@ export function assertProjectRoomInsertProjectCommand(
|
|||
|
||||
export function assertProjectRoomDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomDeleteProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| LegacyProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomDeleteCommandType
|
||||
|
|
@ -419,15 +461,26 @@ export function assertProjectFloorUpdateProjectCommand(
|
|||
|
||||
export function assertProjectRoomUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomUpdateProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectRoomUpdateProjectCommand
|
||||
| LegacyProjectRoomUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== projectRoomUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported project-room update command.");
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
|
||||
assertMatchingRoomUpdate(
|
||||
upgradeLegacyRoomSnapshot(command.payload.expected),
|
||||
upgradeLegacyRoomSnapshot(command.payload.target)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
|
||||
throw new Error("Unsupported project-room update command.");
|
||||
}
|
||||
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
||||
}
|
||||
|
||||
|
|
@ -480,6 +533,51 @@ export function upgradeProjectFloorCommand(
|
|||
}
|
||||
}
|
||||
|
||||
/** Upgrades persisted version-1 room commands with an empty area. */
|
||||
export function upgradeProjectRoomCommand(
|
||||
command:
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| LegacyProjectRoomInsertProjectCommand
|
||||
): ProjectRoomInsertProjectCommand;
|
||||
export function upgradeProjectRoomCommand(
|
||||
command:
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| LegacyProjectRoomDeleteProjectCommand
|
||||
): ProjectRoomDeleteProjectCommand;
|
||||
export function upgradeProjectRoomCommand(
|
||||
command:
|
||||
| ProjectRoomUpdateProjectCommand
|
||||
| LegacyProjectRoomUpdateProjectCommand
|
||||
): ProjectRoomUpdateProjectCommand;
|
||||
export function upgradeProjectRoomCommand(
|
||||
command:
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| ProjectRoomUpdateProjectCommand
|
||||
| LegacyProjectRoomInsertProjectCommand
|
||||
| LegacyProjectRoomDeleteProjectCommand
|
||||
| LegacyProjectRoomUpdateProjectCommand
|
||||
) {
|
||||
if (command.schemaVersion === projectRoomCommandSchemaVersion) {
|
||||
return command;
|
||||
}
|
||||
switch (command.type) {
|
||||
case projectRoomInsertCommandType:
|
||||
return createProjectRoomInsertProjectCommand(
|
||||
upgradeLegacyRoomSnapshot(command.payload.room)
|
||||
);
|
||||
case projectRoomDeleteCommandType:
|
||||
return createProjectRoomDeleteProjectCommand(
|
||||
upgradeLegacyRoomSnapshot(command.payload.room)
|
||||
);
|
||||
case projectRoomUpdateCommandType:
|
||||
return createProjectRoomUpdateProjectCommand(
|
||||
upgradeLegacyRoomSnapshot(command.payload.expected),
|
||||
upgradeLegacyRoomSnapshot(command.payload.target)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
|
||||
assertProjectBuildingSnapshot(expected);
|
||||
assertProjectBuildingSnapshot(target);
|
||||
|
|
@ -568,6 +666,29 @@ function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
|||
export function assertProjectRoomSnapshot(
|
||||
room: unknown
|
||||
): asserts room is ProjectRoomSnapshot {
|
||||
if (!isPlainObject(room) || Object.keys(room).length !== 6) {
|
||||
throw new Error("Project-room snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.id, "room.id");
|
||||
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
|
||||
if (room.floorId !== null) {
|
||||
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
|
||||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
if (
|
||||
room.areaM2 !== null &&
|
||||
(typeof room.areaM2 !== "number" ||
|
||||
!Number.isFinite(room.areaM2) ||
|
||||
room.areaM2 < 0)
|
||||
) {
|
||||
throw new Error("room.areaM2 must be null or a non-negative finite number.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegacyProjectRoomSnapshot(
|
||||
room: unknown
|
||||
): asserts room is LegacyProjectRoomSnapshot {
|
||||
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
|
||||
throw new Error("Project-room snapshot is invalid.");
|
||||
}
|
||||
|
|
@ -580,6 +701,11 @@ export function assertProjectRoomSnapshot(
|
|||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
}
|
||||
|
||||
function upgradeLegacyRoomSnapshot(room: unknown): ProjectRoomSnapshot {
|
||||
assertLegacyProjectRoomSnapshot(room);
|
||||
return { ...room, areaM2: null };
|
||||
}
|
||||
|
||||
function assertProjectBuildingStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
|
|
@ -627,13 +753,19 @@ function assertProjectRoomStructureProjectCommand(
|
|||
| typeof projectRoomDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-room structure command.");
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
|
||||
assertLegacyProjectRoomSnapshot(command.payload.room);
|
||||
return;
|
||||
}
|
||||
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
|
||||
throw new Error("Unsupported project-room structure command.");
|
||||
}
|
||||
assertProjectRoomSnapshot(command.payload.room);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ import {
|
|||
type ExternalModelStateSnapshot,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 8 as const;
|
||||
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
|
|
@ -260,7 +261,7 @@ const floorSchema = legacyFloorSchema
|
|||
.extend({ buildingId: idSchema.nullable() })
|
||||
.strict();
|
||||
|
||||
const roomSchema = z
|
||||
const legacyRoomSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
|
|
@ -270,6 +271,10 @@ const roomSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const roomSchema = legacyRoomSchema
|
||||
.extend({ areaM2: finiteNumberSchema.nonnegative().nullable() })
|
||||
.strict();
|
||||
|
||||
const distributionBoardComponentSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
|
|
@ -504,14 +509,27 @@ export function parseExternalModelStateSnapshot(
|
|||
return externalModelStateSchema.parse(value);
|
||||
}
|
||||
|
||||
// Versions 1-6 have floors without a building and no building list.
|
||||
// Versions 1-6 have floors without a building and no building list. Versions
|
||||
// 1-7 have rooms without an area.
|
||||
const previousProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(legacyFloorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
rooms: z.array(legacyRoomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
const buildingProjectStateSnapshotContents = {
|
||||
...previousProjectStateSnapshotContents,
|
||||
buildings: z.array(buildingSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(legacyRoomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
|
|
@ -520,15 +538,8 @@ const previousProjectStateSnapshotContents = {
|
|||
};
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
...previousProjectStateSnapshotContents,
|
||||
buildings: z.array(buildingSchema),
|
||||
floors: z.array(floorSchema),
|
||||
...buildingProjectStateSnapshotContents,
|
||||
rooms: z.array(roomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
export const projectStateSnapshotSchema = z
|
||||
|
|
@ -542,6 +553,17 @@ export const projectStateSnapshotSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const buildingProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...buildingProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const coverProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
||||
|
|
@ -634,6 +656,20 @@ export function parseProjectStateSnapshot(
|
|||
|
||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
||||
if (
|
||||
cover !== null &&
|
||||
typeof cover === "object" &&
|
||||
!Array.isArray(cover) &&
|
||||
(cover as { schemaVersion?: unknown }).schemaVersion ===
|
||||
buildingProjectStateSnapshotSchemaVersion
|
||||
) {
|
||||
const previous = buildingProjectStateSnapshotSchema.parse(cover);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
|
||||
};
|
||||
}
|
||||
if (
|
||||
cover === null ||
|
||||
typeof cover !== "object" ||
|
||||
|
|
@ -644,14 +680,15 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return cover;
|
||||
}
|
||||
// Every supported version before 7 lacks buildings; its floors had no
|
||||
// building.
|
||||
// building. Build version 7 first so the room-area upgrade above remains a
|
||||
// single exact transition.
|
||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
||||
return {
|
||||
return upgradePreviousProjectStateSnapshot({
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
|
||||
buildings: [],
|
||||
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ export async function updateRoom(req: Request, res: Response) {
|
|||
floorId: parsed.data.floorId,
|
||||
roomNumber: parsed.data.roomNumber,
|
||||
roomName: parsed.data.roomName,
|
||||
areaM2: parsed.data.areaM2 === undefined ? current.areaM2 : parsed.data.areaM2,
|
||||
};
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export const createRoomSchema = z
|
|||
floorId: z.string().trim().min(1).optional(),
|
||||
roomNumber: z.string().trim().min(1).max(50),
|
||||
roomName: z.string().trim().min(1).max(200),
|
||||
areaM2: z.number().finite().nonnegative().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -144,6 +145,7 @@ export const updateRoomSchema = z
|
|||
floorId: z.string().trim().min(1).nullable(),
|
||||
roomNumber: z.string().trim().min(1).max(50),
|
||||
roomName: z.string().trim().min(1).max(200),
|
||||
areaM2: z.number().finite().nonnegative().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue