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 = {

{{ editingRequirement() ? 'Möbelbedarf bearbeiten' : 'Möbelbedarf hinzufügen' }}

+

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. + } Alle Angaben einschließlich Liefertermin sind optional.

+