mandatory

This commit is contained in:
Bastian Wagner
2026-07-20 20:00:15 +02:00
parent 9bc821cdaa
commit 1baf8b6231
21 changed files with 553 additions and 84 deletions

View File

@@ -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],

View File

@@ -224,20 +224,22 @@ const statusLabels: Record<string, string> = {
<h3 id="requirement-form-title">
{{ editingRequirement() ? 'Möbelbedarf bearbeiten' : 'Möbelbedarf hinzufügen' }}
</h3>
<p class="ui-meta wide">Alle Angaben sind optional und können später ergänzt werden.</p>
<label>Name<input formControlName="name" placeholder="Optional" /></label>
<label
>Name *<input formControlName="name" />
@if (requirementForm.controls.name.touched && requirementForm.controls.name.invalid) {
<small class="error">Bitte einen Namen eingeben.</small>
}
</label>
<label
>Raum *<select formControlName="roomId">
<option value="">Bitte wählen</option>
>Raum<select formControlName="roomId">
<option value="">Keinem Raum zuordnen</option>
@for (room of rooms; track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
@if (
optionTargetForm.controls.requirementId.touched &&
optionTargetForm.controls.requirementId.invalid
) {
<small class="error" role="alert">Bitte einen Bedarf auswählen.</small>
}
<label
>Kategorie<select formControlName="category">
@for (entry of categories; track entry[0]) {
@@ -506,19 +508,20 @@ const statusLabels: Record<string, string> = {
<h3 id="option-form-title">
{{ editingOption() ? 'Möbelvorschlag bearbeiten' : 'Möbelvorschlag hinzufügen' }}
</h3>
<label>Name *<input formControlName="name" /></label
<p class="ui-meta wide">Alle Angaben einschließlich Liefertermin sind optional.</p>
<label>Name<input formControlName="name" placeholder="Optional" /></label
><label>Hersteller<input formControlName="manufacturer" /></label
><label>Modell<input formControlName="model" /></label
><label>Händler<input formControlName="retailer" /></label>
<label>Produkt-URL<input type="url" formControlName="productUrl" /></label
><label>Artikelnummer<input formControlName="articleNumber" /></label>
<label
>Einzelpreis *<input
>Einzelpreis<input
type="number"
min="0"
step="0.01"
formControlName="unitPrice" /></label
><label>Menge *<input type="number" min="1" formControlName="quantity" /></label
><label>Menge<input type="number" min="1" formControlName="quantity" /></label
><label
>Versand<input
type="number"
@@ -548,7 +551,9 @@ const statusLabels: Record<string, string> = {
><label
>Lieferzeit (Tage)<input type="number" min="0" formControlName="deliveryDays" /></label
><label
>Erwartete Lieferung<input type="date" formControlName="expectedDeliveryDate"
>Erwartete Lieferung (optional)<input
type="date"
formControlName="expectedDeliveryDate"
/></label>
<label
>Verfügbarkeit<select formControlName="availability">
@@ -786,6 +791,12 @@ const statusLabels: Record<string, string> = {
}
</select></label
>
@if (
assignmentForm.controls.scenarioId.touched &&
assignmentForm.controls.scenarioId.invalid
) {
<small class="error" role="alert">Bitte ein Szenario auswählen.</small>
}
<label
>Bedarf *<select
formControlName="requirementId"
@@ -799,6 +810,12 @@ const statusLabels: Record<string, string> = {
}
</select></label
>
@if (
assignmentForm.controls.requirementId.touched &&
assignmentForm.controls.requirementId.invalid
) {
<small class="error" role="alert">Bitte einen Bedarf auswählen.</small>
}
<fieldset class="option-selection">
<legend>Möbelvorschlag auswählen *</legend>
<div class="option-choice-grid">
@@ -841,6 +858,11 @@ const statusLabels: Record<string, string> = {
}
</div>
</fieldset>
@if (
assignmentForm.controls.optionId.touched && assignmentForm.controls.optionId.invalid
) {
<small class="error" role="alert">Bitte einen Möbelvorschlag auswählen.</small>
}
}
<div class="actions">
@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)

View File

@@ -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<RoomType[]>(`${this.base}/${id}/room-types`);
}
createRoomType(id: string, name: string) {
return this.http.post<RoomType>(`${this.base}/${id}/room-types`, { name });
}
deleteRoomType(id: string, roomTypeId: string) {
return this.http.delete<void>(`${this.base}/${id}/room-types/${roomTypeId}`);
}
tasks(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<RenovationTask>>(`${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<Room>(`${this.base}/${id}/rooms`, body);
}
updateRoom(id: string, room: Room, body: object) {

View File

@@ -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()"
>
<p class="ui-meta form-wide">
Alle Angaben sind optional. Ohne Etage wird die erste vorhandene Etage verwendet.
</p>
<label class="ui-form-field"
><span class="ui-label">Etage</span
><select class="ui-control" formControlName="floorId">
@@ -167,13 +171,53 @@ import type {
><label class="ui-form-field"
><span class="ui-label">Raumtyp</span
><select class="ui-control" formControlName="type">
<option value="living_room">Wohnzimmer</option>
<option value="kitchen">Küche</option>
<option value="bathroom">Badezimmer</option>
<option value="bedroom">Schlafzimmer</option>
<option value="other">Sonstiges</option>
@for (roomType of roomTypes(); track roomType.key) {
<option [value]="roomType.key">{{ roomType.name }}</option>
}
</select></label
><label class="ui-form-field"
>
<div class="ui-form-field">
<span class="ui-label">Eigener Raumtyp</span>
@if (showRoomTypeForm()) {
<input
class="ui-control"
[formControl]="roomTypeName"
aria-describedby="room-type-error"
placeholder="z. B. Hobbyraum"
/>
@if (roomTypeName.touched && roomTypeName.invalid) {
<small id="room-type-error" class="error" role="alert"
>Bitte eine Bezeichnung für den Raumtyp eingeben.</small
>
}
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
[disabled]="saving()"
(click)="createRoomType()"
>
Raumtyp speichern
</button>
<button
class="ui-button ui-button--ghost"
type="button"
(click)="cancelRoomType()"
>
Abbrechen
</button>
</div>
} @else {
<button
class="ui-button ui-button--secondary"
type="button"
(click)="showRoomTypeForm.set(true)"
>
Neuen Raumtyp definieren
</button>
}
</div>
<label class="ui-form-field"
><span class="ui-label">Status</span
><select class="ui-control" formControlName="status">
<option value="unplanned">Nicht geplant</option>
@@ -739,6 +783,7 @@ export class ProjectWorkspacePageComponent implements OnInit {
readonly buildings = signal<Building[]>([]);
readonly floors = signal<Floor[]>([]);
readonly rooms = signal<Room[]>([]);
readonly roomTypes = signal<RoomType[]>([]);
readonly tasks = signal<RenovationTask[]>([]);
readonly milestones = signal<Milestone[]>([]);
readonly budgets = signal<BudgetCategory[]>([]);
@@ -751,6 +796,14 @@ export class ProjectWorkspacePageComponent implements OnInit {
readonly error = signal<string | null>(null);
readonly showRoomForm = signal(false);
readonly editingRoom = signal<Room | null>(null);
readonly showRoomTypeForm = signal(false);
readonly roomTypeName = new FormControl('', {
nonNullable: true,
validators: [
(control) => Validators.required(control),
(control) => Validators.pattern(/\S/)(control),
],
});
readonly showTaskForm = signal(false);
readonly statusFilter = new FormControl('', { nonNullable: true });
readonly mineFilter = new FormControl(false, { nonNullable: true });
@@ -759,14 +812,8 @@ export class ProjectWorkspacePageComponent implements OnInit {
readonly taskPage = signal(1);
readonly taskTotal = signal(0);
readonly roomForm = new FormGroup({
floorId: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
name: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
floorId: new FormControl('', { nonNullable: true }),
name: new FormControl('', { nonNullable: true }),
type: new FormControl('other', { nonNullable: true }),
status: new FormControl('unplanned', { nonNullable: true }),
description: new FormControl('', { nonNullable: true }),
@@ -962,9 +1009,16 @@ export class ProjectWorkspacePageComponent implements OnInit {
createRoom() {
if (this.roomForm.invalid) return;
const editing = this.editingRoom();
const value = this.roomForm.getRawValue();
const body = {
...value,
floorId: value.floorId || undefined,
name: value.name.trim() || undefined,
type: value.type || undefined,
};
const request = editing
? this.api.updateRoom(this.projectId, editing, this.roomForm.getRawValue())
: this.api.createRoom(this.projectId, this.roomForm.getRawValue());
? this.api.updateRoom(this.projectId, editing, body)
: this.api.createRoom(this.projectId, body);
this.save(request, (room) => {
this.rooms.update((items) =>
editing ? items.map((item) => (item.id === room.id ? room : item)) : [...items, room],
@@ -983,6 +1037,23 @@ export class ProjectWorkspacePageComponent implements OnInit {
});
});
}
createRoomType() {
if (this.roomTypeName.invalid) {
this.roomTypeName.markAsTouched();
return;
}
this.save(this.api.createRoomType(this.projectId, this.roomTypeName.value.trim()), (type) => {
this.roomTypes.update((types) =>
[...types, type].sort((a, b) => a.name.localeCompare(b.name)),
);
this.roomForm.controls.type.setValue(type.key);
this.cancelRoomType();
});
}
cancelRoomType() {
this.showRoomTypeForm.set(false);
this.roomTypeName.reset('');
}
editRoom(room: Room) {
this.editingRoom.set(room);
this.showRoomForm.set(true);
@@ -1203,6 +1274,10 @@ export class ProjectWorkspacePageComponent implements OnInit {
this.api
.rooms(this.projectId)
.subscribe({ next: (v) => this.rooms.set(v.items), error: (e: unknown) => this.fail(e) }),
this.api.roomTypes(this.projectId).subscribe({
next: (types) => this.roomTypes.set(types),
error: (e: unknown) => this.fail(e),
}),
this.api.tasks(this.projectId).subscribe({
next: (v) => {
this.tasks.set(v.items);