generated from bastian/boilerplate
mandatory
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user