diff --git a/apps/backend/src/database/entities.ts b/apps/backend/src/database/entities.ts
index d3cdc60..5af0275 100644
--- a/apps/backend/src/database/entities.ts
+++ b/apps/backend/src/database/entities.ts
@@ -22,6 +22,7 @@ import {
ProjectDocumentEntity,
RenovationTaskEntity,
RoomEntity,
+ RoomTypeEntity,
TaskCommentEntity,
TaskDependencyEntity,
TaskCommentMentionEntity,
@@ -54,6 +55,7 @@ export const entities = [
BuildingEntity,
FloorEntity,
RoomEntity,
+ RoomTypeEntity,
RenovationTaskEntity,
ChecklistItemEntity,
TaskDependencyEntity,
diff --git a/apps/backend/src/database/migrations/1720000009000-AddCustomRoomTypes.ts b/apps/backend/src/database/migrations/1720000009000-AddCustomRoomTypes.ts
new file mode 100644
index 0000000..4f327f9
--- /dev/null
+++ b/apps/backend/src/database/migrations/1720000009000-AddCustomRoomTypes.ts
@@ -0,0 +1,32 @@
+import type { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddCustomRoomTypes1720000009000 implements MigrationInterface {
+ name = 'AddCustomRoomTypes1720000009000';
+
+ async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE TABLE room_types (
+ id char(36) NOT NULL,
+ project_id char(36) NOT NULL,
+ \`key\` varchar(40) NOT NULL,
+ name varchar(100) NOT NULL,
+ sort_order int NOT NULL DEFAULT 100,
+ version int NOT NULL DEFAULT 1,
+ created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ PRIMARY KEY(id),
+ UNIQUE KEY uq_room_types_project_key(project_id, \`key\`),
+ UNIQUE KEY uq_room_types_project_name(project_id, name),
+ CONSTRAINT fk_room_types_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
+ await queryRunner.query(
+ 'ALTER TABLE furniture_requirements MODIFY room_id char(36) NULL',
+ );
+ }
+
+ async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ 'ALTER TABLE furniture_requirements MODIFY room_id char(36) NOT NULL',
+ );
+ await queryRunner.query('DROP TABLE room_types');
+ }
+}
diff --git a/apps/backend/src/database/typeorm-cli.datasource.ts b/apps/backend/src/database/typeorm-cli.datasource.ts
index 5d98bcf..09877ba 100644
--- a/apps/backend/src/database/typeorm-cli.datasource.ts
+++ b/apps/backend/src/database/typeorm-cli.datasource.ts
@@ -11,6 +11,7 @@ import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
+import { AddCustomRoomTypes1720000009000 } from './migrations/1720000009000-AddCustomRoomTypes';
const config = loadConfigForCli();
@@ -37,5 +38,6 @@ export default new DataSource({
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
AddRoleAssignmentSources1720000008000,
+ AddCustomRoomTypes1720000009000,
],
});
diff --git a/apps/backend/src/database/typeorm-options.ts b/apps/backend/src/database/typeorm-options.ts
index 53d7266..95b84dd 100644
--- a/apps/backend/src/database/typeorm-options.ts
+++ b/apps/backend/src/database/typeorm-options.ts
@@ -10,6 +10,7 @@ import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
+import { AddCustomRoomTypes1720000009000 } from './migrations/1720000009000-AddCustomRoomTypes';
export function typeOrmOptionsFactory(
config: AppConfigService,
@@ -37,6 +38,7 @@ export function typeOrmOptionsFactory(
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
AddRoleAssignmentSources1720000008000,
+ AddCustomRoomTypes1720000009000,
],
};
}
diff --git a/apps/backend/src/renovation/dto/furniture.dto.spec.ts b/apps/backend/src/renovation/dto/furniture.dto.spec.ts
new file mode 100644
index 0000000..5f7817f
--- /dev/null
+++ b/apps/backend/src/renovation/dto/furniture.dto.spec.ts
@@ -0,0 +1,26 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+import { describe, expect, it } from 'vitest';
+import {
+ CreateFurnitureOptionDto,
+ CreateFurnitureRequirementDto,
+} from './furniture.dto';
+
+describe('optional furniture fields', () => {
+ it('accepts a furniture requirement without a name or room assignment', async () => {
+ const dto = plainToInstance(CreateFurnitureRequirementDto, {});
+
+ expect(await validate(dto)).toEqual([]);
+ });
+
+ it('accepts an empty optional delivery date', async () => {
+ const dto = plainToInstance(CreateFurnitureOptionDto, {
+ expectedDeliveryDate: '',
+ productUrl: '',
+ });
+
+ expect(dto.expectedDeliveryDate).toBeUndefined();
+ expect(dto.productUrl).toBeUndefined();
+ expect(await validate(dto)).toEqual([]);
+ });
+});
diff --git a/apps/backend/src/renovation/dto/furniture.dto.ts b/apps/backend/src/renovation/dto/furniture.dto.ts
index c7fda7e..37beadf 100644
--- a/apps/backend/src/renovation/dto/furniture.dto.ts
+++ b/apps/backend/src/renovation/dto/furniture.dto.ts
@@ -1,4 +1,4 @@
-import { Transform, Type } from 'class-transformer';
+import { Transform, Type, type TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
@@ -28,6 +28,11 @@ import {
FurnitureScenarioType,
} from '../entities/furniture.entities';
+const emptyStringToUndefined = ({ value }: TransformFnParams): unknown => {
+ const input: unknown = value;
+ return input === '' ? undefined : input;
+};
+
export class FurnitureListQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 25;
@@ -110,10 +115,12 @@ export class FurnitureOptionListQueryDto {
}
export class CreateFurnitureRequirementDto {
- @IsUUID() roomId!: string;
- @IsString() @Length(1, 160) name!: string;
+ @IsOptional() @IsUUID() roomId?: string;
+ @IsOptional() @IsString() @Length(0, 160) name?: string;
@IsOptional() @IsString() @Length(0, 5000) description?: string;
- @IsEnum(FurnitureRequirementCategory) category!: FurnitureRequirementCategory;
+ @IsOptional()
+ @IsEnum(FurnitureRequirementCategory)
+ category = FurnitureRequirementCategory.Other;
@IsEnum(FurnitureRequirementPriority) priority =
FurnitureRequirementPriority.Normal;
@Type(() => Number) @IsInt() @Min(1) @Max(10000) requiredQuantity = 1;
@@ -128,23 +135,24 @@ export class UpdateFurnitureRequirementDto extends CreateFurnitureRequirementDto
}
export class CreateFurnitureOptionDto {
- @IsString() @Length(1, 180) name!: string;
+ @IsOptional() @IsString() @Length(0, 180) name?: string;
@IsOptional() @IsString() @Length(0, 160) manufacturer?: string;
@IsOptional() @IsString() @Length(0, 160) model?: string;
@IsOptional() @IsString() @Length(0, 5000) description?: string;
@IsOptional() @IsString() @Length(0, 180) retailer?: string;
@IsOptional()
+ @Transform(emptyStringToUndefined)
@IsUrl({ require_protocol: true, protocols: ['http', 'https'] })
@Length(0, 1000)
productUrl?: string;
@IsOptional() @IsString() @Length(0, 120) articleNumber?: string;
- @Type(() => Number) @IsNumber() @Min(0) unitPrice!: number;
+ @IsOptional() @Type(() => Number) @IsNumber() @Min(0) unitPrice = 0;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) originalPrice?: number;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) shippingCost = 0;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) additionalCost = 0;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) discount = 0;
@IsString() @Length(3, 3) currency = 'EUR';
- @Type(() => Number) @IsInt() @Min(1) @Max(10000) quantity = 1;
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(10000) quantity = 1;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) width?: number;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) height?: number;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) depth?: number;
@@ -152,9 +160,18 @@ export class CreateFurnitureOptionDto {
@IsOptional() @IsString() @Length(0, 100) color?: string;
@IsOptional() @IsString() @Length(0, 160) material?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) deliveryDays?: number;
- @IsOptional() @IsDateString() earliestDeliveryDate?: string;
- @IsOptional() @IsDateString() expectedDeliveryDate?: string;
- @IsOptional() @IsDateString() returnDeadline?: string;
+ @Transform(emptyStringToUndefined)
+ @IsOptional()
+ @IsDateString()
+ earliestDeliveryDate?: string;
+ @Transform(emptyStringToUndefined)
+ @IsOptional()
+ @IsDateString()
+ expectedDeliveryDate?: string;
+ @Transform(emptyStringToUndefined)
+ @IsOptional()
+ @IsDateString()
+ returnDeadline?: string;
@IsEnum(FurnitureAvailability) availability = FurnitureAvailability.Unknown;
@IsOptional() @IsBoolean() favorite = false;
@IsEnum(FurnitureOptionStatus) status = FurnitureOptionStatus.Idea;
diff --git a/apps/backend/src/renovation/dto/renovation.dto.spec.ts b/apps/backend/src/renovation/dto/renovation.dto.spec.ts
new file mode 100644
index 0000000..171125e
--- /dev/null
+++ b/apps/backend/src/renovation/dto/renovation.dto.spec.ts
@@ -0,0 +1,19 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+import { describe, expect, it } from 'vitest';
+import { CreateRoomDto, CreateRoomTypeDto } from './renovation.dto';
+
+describe('optional room fields', () => {
+ it('accepts room creation without user-provided fields', async () => {
+ const dto = plainToInstance(CreateRoomDto, {});
+
+ expect(dto.status).toBe('unplanned');
+ expect(await validate(dto)).toEqual([]);
+ });
+
+ it('rejects a room type without a visible name', async () => {
+ const dto = plainToInstance(CreateRoomTypeDto, { name: ' ' });
+
+ expect(await validate(dto)).not.toEqual([]);
+ });
+});
diff --git a/apps/backend/src/renovation/dto/renovation.dto.ts b/apps/backend/src/renovation/dto/renovation.dto.ts
index 8bfb428..72c2e90 100644
--- a/apps/backend/src/renovation/dto/renovation.dto.ts
+++ b/apps/backend/src/renovation/dto/renovation.dto.ts
@@ -27,6 +27,11 @@ function commaSeparated(value: unknown): unknown {
return typeof value === 'string' ? value.split(',').filter(Boolean) : value;
}
+function trimmedString({ value }: TransformFnParams): unknown {
+ const input: unknown = value;
+ return typeof input === 'string' ? input.trim() : input;
+}
+
export class VersionDto {
@Type(() => Number) @IsInt() @Min(1) version!: number;
}
@@ -52,10 +57,10 @@ export class UpdateFloorDto extends CreateFloorDto {
}
export class CreateRoomDto {
- @IsUUID() floorId!: string;
- @IsString() @Length(1, 160) name!: string;
+ @IsOptional() @IsUUID() floorId?: string;
+ @IsOptional() @IsString() @Length(0, 160) name?: string;
@IsOptional() @IsString() @Length(0, 4000) description?: string;
- @IsString() @Length(1, 40) type!: string;
+ @IsOptional() @IsString() @Length(0, 40) type?: string;
@IsEnum(RoomStatus) status: RoomStatus = RoomStatus.Unplanned;
@IsOptional()
@Type(() => Number)
@@ -66,6 +71,11 @@ export class CreateRoomDto {
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) plannedBudget?: number;
@IsOptional() @Type(() => Number) @IsInt() sortOrder?: number;
}
+
+export class CreateRoomTypeDto {
+ @Transform(trimmedString) @IsString() @Length(1, 100) name!: string;
+ @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number;
+}
export class UpdateRoomDto extends CreateRoomDto {
@Type(() => Number) @IsInt() @Min(1) version!: number;
}
diff --git a/apps/backend/src/renovation/entities/furniture.entities.ts b/apps/backend/src/renovation/entities/furniture.entities.ts
index bdc2b88..fc26dff 100644
--- a/apps/backend/src/renovation/entities/furniture.entities.ts
+++ b/apps/backend/src/renovation/entities/furniture.entities.ts
@@ -115,7 +115,8 @@ export enum FurnitureScenarioStatus {
])
export class FurnitureRequirementEntity extends FurnitureVersionedEntity {
@Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string;
- @Column({ name: 'room_id', type: 'char', length: 36 }) roomId!: string;
+ @Column({ name: 'room_id', type: 'char', length: 36, nullable: true })
+ roomId!: string | null;
@Column({ type: 'varchar', length: 160 }) name!: string;
@Column({ type: 'text', nullable: true }) description!: string | null;
@Column({ type: 'varchar', length: 30 })
diff --git a/apps/backend/src/renovation/entities/renovation.entities.ts b/apps/backend/src/renovation/entities/renovation.entities.ts
index a9ab137..104d3ba 100644
--- a/apps/backend/src/renovation/entities/renovation.entities.ts
+++ b/apps/backend/src/renovation/entities/renovation.entities.ts
@@ -68,6 +68,16 @@ export enum RoomStatus {
Omitted = 'omitted',
}
+@Entity('room_types')
+@Index('uq_room_types_project_key', ['projectId', 'key'], { unique: true })
+@Index('uq_room_types_project_name', ['projectId', 'name'], { unique: true })
+export class RoomTypeEntity extends VersionedEntity {
+ @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string;
+ @Column({ type: 'varchar', length: 40 }) key!: string;
+ @Column({ type: 'varchar', length: 100 }) name!: string;
+ @Column({ name: 'sort_order', type: 'int', default: 100 }) sortOrder!: number;
+}
+
@Entity('rooms')
@Index('idx_rooms_project_floor_sort', ['projectId', 'floorId', 'sortOrder'])
export class RoomEntity extends VersionedEntity {
diff --git a/apps/backend/src/renovation/furniture.service.ts b/apps/backend/src/renovation/furniture.service.ts
index 7b60c22..309469c 100644
--- a/apps/backend/src/renovation/furniture.service.ts
+++ b/apps/backend/src/renovation/furniture.service.ts
@@ -96,8 +96,8 @@ export class FurnitureService {
const saved = await this.furniture.requirements.save(
this.furniture.requirements.create({
projectId,
- roomId: dto.roomId,
- name: dto.name.trim(),
+ roomId: dto.roomId ?? null,
+ name: dto.name?.trim() || 'Unbenannter Möbelbedarf',
description: dto.description?.trim() || null,
category: dto.category,
priority: dto.priority,
@@ -144,8 +144,8 @@ export class FurnitureService {
id,
dto.version,
{
- roomId: dto.roomId,
- name: dto.name.trim(),
+ roomId: dto.roomId ?? null,
+ name: dto.name?.trim() || 'Unbenannter Möbelbedarf',
description: dto.description?.trim() || null,
category: dto.category,
priority: dto.priority,
@@ -159,10 +159,10 @@ export class FurnitureService {
await this.activity(
projectId,
userId,
- before.roomId === dto.roomId
+ before.roomId === (dto.roomId ?? null)
? 'furniture.requirement-updated'
: 'furniture.requirement-moved',
- { requirementId: id, roomId: dto.roomId },
+ { requirementId: id, roomId: dto.roomId ?? null },
);
return this.owned(this.furniture.requirements, projectId, id);
}
@@ -1016,7 +1016,7 @@ export class FurnitureService {
const requirement = requirements.find(
(entry) => entry.id === selection.requirementId,
);
- if (requirement)
+ if (requirement?.roomId)
byRoom[requirement.roomId] = sumMoney([
byRoom[requirement.roomId] ?? '0',
totalFor(selection),
@@ -1123,7 +1123,7 @@ export class FurnitureService {
const decimal = (value?: number) =>
value === undefined ? null : value.toFixed(2);
return {
- name: dto.name.trim(),
+ name: dto.name?.trim() || 'Unbenannter Möbelvorschlag',
manufacturer: text(dto.manufacturer),
model: text(dto.model),
description: text(dto.description),
@@ -1222,10 +1222,10 @@ export class FurnitureService {
}
private async validateRequirementLinks(
projectId: string,
- roomId: string,
+ roomId?: string,
responsibleUserId?: string,
) {
- await this.owned(this.renovation.rooms, projectId, roomId);
+ if (roomId) await this.owned(this.renovation.rooms, projectId, roomId);
if (responsibleUserId) {
const member = await this.projects.findMembership(
projectId,
diff --git a/apps/backend/src/renovation/renovation.controller.ts b/apps/backend/src/renovation/renovation.controller.ts
index ffdf732..e184cb3 100644
--- a/apps/backend/src/renovation/renovation.controller.ts
+++ b/apps/backend/src/renovation/renovation.controller.ts
@@ -31,6 +31,7 @@ import {
CreateFloorDto,
CreateMilestoneDto,
CreateRoomDto,
+ CreateRoomTypeDto,
CreateTaskDto,
DocumentMetadataDto,
DocumentListQueryDto,
@@ -126,6 +127,26 @@ export class RenovationController {
) {
return this.service.rooms(p, this.user(r), q);
}
+ @Get('projects/:projectId/room-types') roomTypes(
+ @Req() r: AuthenticatedRequest,
+ @Param('projectId') p: string,
+ ) {
+ return this.service.roomTypes(p, this.user(r));
+ }
+ @Post('projects/:projectId/room-types') createRoomType(
+ @Req() r: AuthenticatedRequest,
+ @Param('projectId') p: string,
+ @Body() d: CreateRoomTypeDto,
+ ) {
+ return this.service.createRoomType(p, this.user(r), d);
+ }
+ @Delete('projects/:projectId/room-types/:id') deleteRoomType(
+ @Req() r: AuthenticatedRequest,
+ @Param('projectId') p: string,
+ @Param('id') id: string,
+ ) {
+ return this.service.deleteRoomType(p, id, this.user(r));
+ }
@Post('projects/:projectId/rooms') createRoom(
@Req() r: AuthenticatedRequest,
@Param('projectId') p: string,
diff --git a/apps/backend/src/renovation/renovation.module.ts b/apps/backend/src/renovation/renovation.module.ts
index 4aa7675..4e78a19 100644
--- a/apps/backend/src/renovation/renovation.module.ts
+++ b/apps/backend/src/renovation/renovation.module.ts
@@ -22,6 +22,7 @@ import {
ProjectDocumentEntity,
RenovationTaskEntity,
RoomEntity,
+ RoomTypeEntity,
TaskCommentEntity,
TaskDependencyEntity,
TaskCommentMentionEntity,
@@ -48,6 +49,7 @@ const renovationEntities = [
BuildingEntity,
FloorEntity,
RoomEntity,
+ RoomTypeEntity,
RenovationTaskEntity,
ChecklistItemEntity,
TaskDependencyEntity,
diff --git a/apps/backend/src/renovation/renovation.repository.ts b/apps/backend/src/renovation/renovation.repository.ts
index d7a125e..f6981e8 100644
--- a/apps/backend/src/renovation/renovation.repository.ts
+++ b/apps/backend/src/renovation/renovation.repository.ts
@@ -20,6 +20,7 @@ import {
ProjectDocumentEntity,
RenovationTaskEntity,
RoomEntity,
+ RoomTypeEntity,
TaskCommentEntity,
TaskCommentMentionEntity,
TaskDependencyEntity,
@@ -34,6 +35,8 @@ export class RenovationRepository {
readonly buildings: Repository,
@InjectRepository(FloorEntity) readonly floors: Repository,
@InjectRepository(RoomEntity) readonly rooms: Repository,
+ @InjectRepository(RoomTypeEntity)
+ readonly roomTypes: Repository,
@InjectRepository(RenovationTaskEntity)
readonly tasks: Repository,
@InjectRepository(ChecklistItemEntity)
diff --git a/apps/backend/src/renovation/renovation.service.ts b/apps/backend/src/renovation/renovation.service.ts
index e2a2850..9e8a4ec 100644
--- a/apps/backend/src/renovation/renovation.service.ts
+++ b/apps/backend/src/renovation/renovation.service.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } from 'typeorm';
@@ -29,6 +30,7 @@ import {
CreateFloorDto,
CreateMilestoneDto,
CreateRoomDto,
+ CreateRoomTypeDto,
CreateTaskDto,
UpdateBudgetCategoryDto,
UpdateBuildingDto,
@@ -69,6 +71,7 @@ import type { TaskTemplate } from './templates';
import { newMentionUserIds } from './mentions';
import { FurnitureService } from './furniture.service';
import { FurnitureRequirementEntity } from './entities/furniture.entities';
+import { customRoomTypeKey, defaultRoomTypes } from './room-types';
@Injectable()
export class RenovationService {
@@ -91,6 +94,78 @@ export class RenovationService {
return this.repo.listFloors(projectId);
}
+ async roomTypes(projectId: string, userId: string) {
+ await this.read(projectId, userId);
+ const customTypes = await this.repo.roomTypes.find({
+ where: { projectId },
+ order: { sortOrder: 'ASC', name: 'ASC' },
+ });
+ return [
+ ...defaultRoomTypes,
+ ...customTypes.map((type) => ({
+ id: type.id,
+ key: type.key,
+ name: type.name,
+ custom: true,
+ sortOrder: type.sortOrder,
+ })),
+ ].sort(
+ (left, right) =>
+ left.sortOrder - right.sortOrder || left.name.localeCompare(right.name),
+ );
+ }
+
+ async createRoomType(
+ projectId: string,
+ userId: string,
+ dto: CreateRoomTypeDto,
+ ) {
+ await this.edit(projectId, userId);
+ const name = dto.name.trim();
+ const duplicateDefault = defaultRoomTypes.some(
+ (type) =>
+ type.name.toLocaleLowerCase('de-DE') ===
+ name.toLocaleLowerCase('de-DE'),
+ );
+ const duplicateCustom = await this.repo.roomTypes.findOneBy({
+ projectId,
+ name,
+ });
+ if (duplicateDefault || duplicateCustom)
+ this.conflict('Dieser Raumtyp ist bereits vorhanden.');
+ const saved = await this.repo.roomTypes.save(
+ this.repo.roomTypes.create({
+ projectId,
+ key: customRoomTypeKey(name, randomUUID().replaceAll('-', '')),
+ name,
+ sortOrder: dto.sortOrder ?? 100,
+ }),
+ );
+ return {
+ id: saved.id,
+ key: saved.key,
+ name: saved.name,
+ custom: true,
+ sortOrder: saved.sortOrder,
+ };
+ }
+
+ async deleteRoomType(projectId: string, id: string, userId: string) {
+ await this.edit(projectId, userId);
+ const roomType = await this.requireOwned(
+ this.repo.roomTypes,
+ projectId,
+ id,
+ );
+ if (
+ await this.repo.rooms.exist({ where: { projectId, type: roomType.key } })
+ )
+ this.conflict(
+ 'Der Raumtyp wird noch von mindestens einem Raum verwendet.',
+ );
+ await this.repo.roomTypes.delete({ id, projectId });
+ }
+
async ensureDefaultFloors(projectId: string, userId: string) {
await this.edit(projectId, userId);
return this.dataSource.transaction(async (manager) => {
@@ -580,14 +655,16 @@ export class RenovationService {
async createRoom(projectId: string, userId: string, dto: CreateRoomDto) {
await this.edit(projectId, userId);
- await this.requireOwned(this.repo.floors, projectId, dto.floorId);
+ const floorId = await this.resolveRoomFloor(projectId, userId, dto.floorId);
+ const type = dto.type?.trim() || 'other';
+ await this.validateRoomType(projectId, type);
const saved = await this.repo.rooms.save(
this.repo.rooms.create({
projectId,
- floorId: dto.floorId,
- name: dto.name.trim(),
+ floorId,
+ name: dto.name?.trim() || 'Unbenannter Raum',
description: dto.description?.trim() || null,
- type: dto.type,
+ type,
status: dto.status,
area: this.decimal(dto.area),
plannedBudget: this.decimal(dto.plannedBudget),
@@ -608,13 +685,16 @@ export class RenovationService {
dto: UpdateRoomDto,
) {
await this.edit(projectId, userId);
- await this.requireOwned(this.repo.floors, projectId, dto.floorId);
const before = await this.requireOwned(this.repo.rooms, projectId, id);
+ const floorId = dto.floorId ?? before.floorId;
+ await this.requireOwned(this.repo.floors, projectId, floorId);
+ const type = dto.type?.trim() || before.type;
+ await this.validateRoomType(projectId, type);
await this.versioned(this.repo.rooms, projectId, id, dto.version, {
- floorId: dto.floorId,
- name: dto.name.trim(),
+ floorId,
+ name: dto.name?.trim() || 'Unbenannter Raum',
description: dto.description?.trim() || null,
- type: dto.type,
+ type,
status: dto.status,
area: this.decimal(dto.area),
plannedBudget: this.decimal(dto.plannedBudget),
@@ -1611,6 +1691,42 @@ export class RenovationService {
private read(projectId: string, userId: string) {
return this.access.require(projectId, userId, 'read');
}
+ private async resolveRoomFloor(
+ projectId: string,
+ userId: string,
+ requestedFloorId?: string,
+ ): Promise {
+ if (requestedFloorId) {
+ await this.requireOwned(this.repo.floors, projectId, requestedFloorId);
+ return requestedFloorId;
+ }
+ const existing = await this.repo.floors.findOne({
+ where: { projectId },
+ order: { sortOrder: 'ASC' },
+ });
+ if (existing) return existing.id;
+ const defaults = await this.ensureDefaultFloors(projectId, userId);
+ const first = defaults.floors[0];
+ if (!first)
+ throw new ApiError(
+ ErrorCode.ValidationFailed,
+ 'Es konnte keine Standardetage angelegt werden.',
+ 400,
+ );
+ return first.id;
+ }
+ private async validateRoomType(
+ projectId: string,
+ key: string,
+ ): Promise {
+ if (defaultRoomTypes.some((type) => type.key === key)) return;
+ if (await this.repo.roomTypes.exist({ where: { projectId, key } })) return;
+ throw new ApiError(
+ ErrorCode.ValidationFailed,
+ 'Der ausgewählte Raumtyp existiert nicht.',
+ 400,
+ );
+ }
private edit(projectId: string, userId: string) {
return this.access.require(projectId, userId, 'edit');
}
diff --git a/apps/backend/src/renovation/room-types.spec.ts b/apps/backend/src/renovation/room-types.spec.ts
new file mode 100644
index 0000000..d89977c
--- /dev/null
+++ b/apps/backend/src/renovation/room-types.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { customRoomTypeKey, defaultRoomTypes } from './room-types';
+
+describe('room types', () => {
+ it('provides stable defaults including the fallback type', () => {
+ expect(defaultRoomTypes.map((type) => type.key)).toContain('other');
+ expect(new Set(defaultRoomTypes.map((type) => type.key)).size).toBe(
+ defaultRoomTypes.length,
+ );
+ });
+
+ it('creates bounded technical keys from user-defined labels', () => {
+ expect(customRoomTypeKey('Gäste- & Arbeitszimmer', '1234567890')).toBe(
+ 'custom_gaste_arbeitszimmer_12345678',
+ );
+ expect(customRoomTypeKey('🎉', 'abcdef12')).toBe('custom_raum_abcdef12');
+ expect(
+ customRoomTypeKey('x'.repeat(100), 'abcdef12').length,
+ ).toBeLessThanOrEqual(40);
+ });
+});
diff --git a/apps/backend/src/renovation/room-types.ts b/apps/backend/src/renovation/room-types.ts
new file mode 100644
index 0000000..bb9040d
--- /dev/null
+++ b/apps/backend/src/renovation/room-types.ts
@@ -0,0 +1,44 @@
+export interface RoomTypeDefinition {
+ id: string | null;
+ key: string;
+ name: string;
+ custom: boolean;
+ sortOrder: number;
+}
+
+export const defaultRoomTypes: readonly RoomTypeDefinition[] = [
+ {
+ id: null,
+ key: 'living_room',
+ name: 'Wohnzimmer',
+ custom: false,
+ sortOrder: 10,
+ },
+ { id: null, key: 'kitchen', name: 'Küche', custom: false, sortOrder: 20 },
+ {
+ id: null,
+ key: 'bathroom',
+ name: 'Badezimmer',
+ custom: false,
+ sortOrder: 30,
+ },
+ {
+ id: null,
+ key: 'bedroom',
+ name: 'Schlafzimmer',
+ custom: false,
+ sortOrder: 40,
+ },
+ { id: null, key: 'other', name: 'Sonstiges', custom: false, sortOrder: 1000 },
+];
+
+export function customRoomTypeKey(name: string, suffix: string): string {
+ const slug = name
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .slice(0, 24);
+ return `custom_${slug || 'raum'}_${suffix.slice(0, 8)}`;
+}
diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts
index 376c02d..970e573 100644
--- a/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts
+++ b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts
@@ -15,6 +15,30 @@ import { FurniturePlanningComponent } from './furniture-planning.component';
registerLocaleData(localeDe);
describe('FurniturePlanningComponent', () => {
+ it('allows optional furniture and delivery fields to stay empty', async () => {
+ await TestBed.configureTestingModule({
+ imports: [FurniturePlanningComponent],
+ providers: [provideHttpClient()],
+ }).compileComponents();
+ const component = TestBed.createComponent(FurniturePlanningComponent).componentInstance;
+
+ component.requirementForm.reset({
+ name: '',
+ roomId: '',
+ description: '',
+ category: 'other',
+ priority: 'normal',
+ requiredQuantity: 1,
+ maximumBudget: null,
+ status: 'identified',
+ sortOrder: 0,
+ });
+ component.optionForm.patchValue({ name: '', expectedDeliveryDate: '' });
+
+ expect(component.requirementForm.valid).toBe(true);
+ expect(component.optionForm.valid).toBe(true);
+ });
+
it('explains the planning concepts and recommends the next useful step', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.ts
index c2b1437..fc2af0b 100644
--- a/apps/frontend/src/app/features/projects/furniture-planning.component.ts
+++ b/apps/frontend/src/app/features/projects/furniture-planning.component.ts
@@ -224,20 +224,22 @@ const statusLabels: Record = {
+ Alle Angaben sind optional und können später ergänzt werden.
+
-
+ @if (
+ optionTargetForm.controls.requirementId.touched &&
+ optionTargetForm.controls.requirementId.invalid
+ ) {
+ Bitte einen Bedarf auswählen.
+ }
+ NameHerstellerModellHändler
Produkt-URLArtikelnummer
Einzelpreis *EinzelpreisMenge *MengeVersand = {
>Lieferzeit (Tage)Erwartete LieferungErwartete Lieferung (optional)
Verfügbarkeit
+ @if (
+ assignmentForm.controls.scenarioId.touched &&
+ assignmentForm.controls.scenarioId.invalid
+ ) {
+ Bitte ein Szenario auswählen.
+ }
Bedarf *
+ @if (
+ assignmentForm.controls.requirementId.touched &&
+ assignmentForm.controls.requirementId.invalid
+ ) {
+ Bitte einen Bedarf auswählen.
+ }
+ @if (
+ assignmentForm.controls.optionId.touched && assignmentForm.controls.optionId.invalid
+ ) {
+ Bitte einen Möbelvorschlag auswählen.
+ }
}
@if (!scenarios().length) {
@@ -1140,14 +1162,8 @@ export class FurniturePlanningComponent implements OnChanges {
sortBy: new FormControl('sortOrder', { nonNullable: true }),
});
readonly requirementForm = new FormGroup({
- name: new FormControl('', {
- nonNullable: true,
- validators: [(control) => Validators.required(control)],
- }),
- roomId: new FormControl('', {
- nonNullable: true,
- validators: [(control) => Validators.required(control)],
- }),
+ name: new FormControl('', { nonNullable: true }),
+ roomId: new FormControl('', { nonNullable: true }),
description: new FormControl('', { nonNullable: true }),
category: new FormControl('other', { nonNullable: true }),
priority: new FormControl('normal', { nonNullable: true }),
@@ -1162,10 +1178,7 @@ export class FurniturePlanningComponent implements OnChanges {
sortOrder: new FormControl(0, { nonNullable: true }),
});
readonly optionForm = new FormGroup({
- name: new FormControl('', {
- nonNullable: true,
- validators: [(control) => Validators.required(control)],
- }),
+ name: new FormControl('', { nonNullable: true }),
manufacturer: new FormControl('', { nonNullable: true }),
model: new FormControl('', { nonNullable: true }),
description: new FormControl('', { nonNullable: true }),
@@ -1276,7 +1289,8 @@ export class FurniturePlanningComponent implements OnChanges {
this.filterForm.reset({ search: '', roomId: '', status: '', sortBy: 'sortOrder' });
this.load(1);
}
- roomName(id: string) {
+ roomName(id: string | null) {
+ if (!id) return 'Keinem Raum zugeordnet';
return this.rooms.find((room) => room.id === id)?.name ?? 'Unbekannter Raum';
}
category(value: string) {
@@ -1293,7 +1307,7 @@ export class FurniturePlanningComponent implements OnChanges {
this.editingRequirement.set(null);
this.requirementForm.reset({
name: '',
- roomId: this.filterForm.controls.roomId.value || this.rooms[0]?.id || '',
+ roomId: this.filterForm.controls.roomId.value,
description: '',
category: 'other',
priority: 'normal',
@@ -1309,7 +1323,7 @@ export class FurniturePlanningComponent implements OnChanges {
this.editingRequirement.set(r);
this.requirementForm.reset({
name: r.name,
- roomId: r.roomId,
+ roomId: r.roomId ?? '',
description: r.description ?? '',
category: r.category,
priority: r.priority,
@@ -1331,13 +1345,11 @@ export class FurniturePlanningComponent implements OnChanges {
if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched();
this.saving.set(true);
const existing = this.editingRequirement();
+ const value = this.requirementForm.getRawValue();
+ const body = { ...value, roomId: value.roomId || undefined };
const request = existing
- ? this.api.updateFurnitureRequirement(
- this.projectId,
- existing,
- this.requirementForm.getRawValue(),
- )
- : this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue());
+ ? this.api.updateFurnitureRequirement(this.projectId, existing, body)
+ : this.api.createFurnitureRequirement(this.projectId, body);
request.subscribe({
next: (saved) => {
this.saving.set(false);
@@ -1457,7 +1469,13 @@ export class FurniturePlanningComponent implements OnChanges {
const requirement = this.optionRequirement();
if (!requirement || this.optionForm.invalid) return this.optionForm.markAllAsTouched();
this.saving.set(true);
- const body = { ...this.optionForm.getRawValue(), currency: 'EUR' };
+ const value = this.optionForm.getRawValue();
+ const body = {
+ ...value,
+ name: value.name.trim() || undefined,
+ expectedDeliveryDate: value.expectedDeliveryDate || undefined,
+ currency: 'EUR',
+ };
const existing = this.editingOption();
const request = existing
? this.api.updateFurnitureOption(this.projectId, existing, body)
diff --git a/apps/frontend/src/app/features/projects/hauspilot-api.service.ts b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts
index b5d73de..883304a 100644
--- a/apps/frontend/src/app/features/projects/hauspilot-api.service.ts
+++ b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts
@@ -40,6 +40,13 @@ export interface Room extends Versioned {
description: string | null;
previewDocumentId: string | null;
}
+export interface RoomType {
+ id: string | null;
+ key: string;
+ name: string;
+ custom: boolean;
+ sortOrder: number;
+}
export interface RenovationTask extends Versioned {
projectId: string;
roomId: string | null;
@@ -231,7 +238,7 @@ export interface FurnitureOption extends Versioned {
}
export interface FurnitureRequirement extends Versioned {
projectId: string;
- roomId: string;
+ roomId: string | null;
name: string;
description: string | null;
category: string;
@@ -318,6 +325,15 @@ export class HauspilotApiService {
params: this.params(query),
});
}
+ roomTypes(id: string) {
+ return this.http.get
(`${this.base}/${id}/room-types`);
+ }
+ createRoomType(id: string, name: string) {
+ return this.http.post(`${this.base}/${id}/room-types`, { name });
+ }
+ deleteRoomType(id: string, roomTypeId: string) {
+ return this.http.delete(`${this.base}/${id}/room-types/${roomTypeId}`);
+ }
tasks(id: string, query: ListQuery = {}) {
return this.http.get>(`${this.base}/${id}/tasks`, {
params: this.params(query),
@@ -385,7 +401,15 @@ export class HauspilotApiService {
version: floor.version,
});
}
- createRoom(id: string, body: { floorId: string; name: string; type: string; status: string }) {
+ createRoom(
+ id: string,
+ body: {
+ floorId?: string | undefined;
+ name?: string | undefined;
+ type?: string | undefined;
+ status?: string | undefined;
+ },
+ ) {
return this.http.post(`${this.base}/${id}/rooms`, body);
}
updateRoom(id: string, room: Room, body: object) {
diff --git a/apps/frontend/src/app/features/projects/project-workspace.page.ts b/apps/frontend/src/app/features/projects/project-workspace.page.ts
index a4a678b..203f46e 100644
--- a/apps/frontend/src/app/features/projects/project-workspace.page.ts
+++ b/apps/frontend/src/app/features/projects/project-workspace.page.ts
@@ -28,6 +28,7 @@ import type {
ProjectDocument,
RenovationTask,
Room,
+ RoomType,
} from './hauspilot-api.service';
@Component({
@@ -151,6 +152,9 @@ import type {
[formGroup]="roomForm"
(ngSubmit)="createRoom()"
>
+
+ Alle Angaben sind optional. Ohne Etage wird die erste vorhandene Etage verwendet.
+
Etage