generated from bastian/boilerplate
fracturing
This commit is contained in:
@@ -110,6 +110,7 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
|
||||
imports: [AgGridAngular, CurrencyPipe, FormsModule],
|
||||
template: `
|
||||
<section class="grid-shell" aria-labelledby="furniture-grid-title">
|
||||
@if (!fixedView) {
|
||||
<div class="grid-tabs" role="tablist" aria-label="Möbelansichten">
|
||||
@for (entry of views; track entry.id) {
|
||||
<button
|
||||
@@ -123,6 +124,7 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="toolbar ui-card">
|
||||
<label
|
||||
>Suche
|
||||
@@ -327,6 +329,8 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
@Input() rooms: Room[] = [];
|
||||
@Input() canEdit = false;
|
||||
@Input() scenarios: FurnitureScenario[] = [];
|
||||
@Input() fixedView: View | null = null;
|
||||
@Input() dialogEditing = false;
|
||||
@Output() editRequirement = new EventEmitter<FurnitureRequirement>();
|
||||
@Output() addOption = new EventEmitter<FurnitureRequirement>();
|
||||
@Output() editOption = new EventEmitter<FurnitureOption>();
|
||||
@@ -379,6 +383,7 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
.subscribe(() => this.load(1));
|
||||
}
|
||||
ngOnChanges() {
|
||||
if (this.fixedView) this.view.set(this.fixedView);
|
||||
this.refreshColumns();
|
||||
if (this.projectId) this.load(1);
|
||||
}
|
||||
@@ -506,18 +511,23 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
field: 'roomId',
|
||||
headerName: 'Raum',
|
||||
valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '–',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: { values: this.rooms.map((r) => r.id) },
|
||||
sort: 'asc',
|
||||
colId: 'room',
|
||||
},
|
||||
{ field: 'category', headerName: 'Kategorie', editable: this.canEdit },
|
||||
{ field: 'name', headerName: 'Möbelbedarf', editable: this.canEdit, minWidth: 180 },
|
||||
{ field: 'category', headerName: 'Kategorie', editable: this.canEdit && !this.dialogEditing },
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Möbelbedarf',
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'priority',
|
||||
headerName: 'Priorität',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: { values: Object.keys(requirementPriorities) },
|
||||
valueFormatter: (params) => this.present(params.value, requirementPriorities),
|
||||
@@ -527,13 +537,13 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
{
|
||||
field: 'requiredQuantity',
|
||||
headerName: 'Menge',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
type: 'numericColumn',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: { values: Object.keys(requirementStatuses) },
|
||||
valueFormatter: (params) => this.present(params.value, requirementStatuses),
|
||||
@@ -543,7 +553,7 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
{
|
||||
field: 'maximumBudget',
|
||||
headerName: 'Maximalbudget',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
valueFormatter: (p) => money(p.value),
|
||||
valueParser: (p) => parseGermanNumber(p.newValue),
|
||||
},
|
||||
@@ -644,9 +654,18 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
return [
|
||||
{ field: 'roomName', headerName: 'Raum', colId: 'room' },
|
||||
{ field: 'requirementName', headerName: 'Möbelbedarf', colId: 'requirement', minWidth: 170 },
|
||||
{ field: 'name', headerName: 'Produkt', editable: this.canEdit, minWidth: 180 },
|
||||
{ field: 'manufacturer', headerName: 'Hersteller', editable: this.canEdit },
|
||||
{ field: 'retailer', headerName: 'Händler', editable: this.canEdit },
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Produkt',
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'manufacturer',
|
||||
headerName: 'Hersteller',
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
},
|
||||
{ field: 'retailer', headerName: 'Händler', editable: this.canEdit && !this.dialogEditing },
|
||||
{ field: 'articleNumber', headerName: 'Artikelnummer' },
|
||||
...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map(
|
||||
(field) => ({
|
||||
@@ -658,29 +677,33 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
additionalCost: 'Zusatzkosten',
|
||||
discount: 'Rabatt',
|
||||
}[field],
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
valueFormatter:
|
||||
field === 'quantity' ? undefined : (p: { value: unknown }) => money(p.value),
|
||||
valueParser: (p: { newValue: unknown }) => parseGermanNumber(p.newValue),
|
||||
}),
|
||||
),
|
||||
{ field: 'totalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => money(p.value) },
|
||||
{ field: 'color', headerName: 'Farbe', editable: this.canEdit },
|
||||
{ field: 'material', headerName: 'Material', editable: this.canEdit },
|
||||
{ field: 'width', headerName: 'Breite', editable: this.canEdit },
|
||||
{ field: 'height', headerName: 'Höhe', editable: this.canEdit },
|
||||
{ field: 'depth', headerName: 'Tiefe', editable: this.canEdit },
|
||||
{ field: 'color', headerName: 'Farbe', editable: this.canEdit && !this.dialogEditing },
|
||||
{ field: 'material', headerName: 'Material', editable: this.canEdit && !this.dialogEditing },
|
||||
{ field: 'width', headerName: 'Breite', editable: this.canEdit && !this.dialogEditing },
|
||||
{ field: 'height', headerName: 'Höhe', editable: this.canEdit && !this.dialogEditing },
|
||||
{ field: 'depth', headerName: 'Tiefe', editable: this.canEdit && !this.dialogEditing },
|
||||
{
|
||||
field: 'availability',
|
||||
headerName: 'Verfügbarkeit',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: { values: Object.keys(availabilityValues) },
|
||||
valueFormatter: (params) => this.present(params.value, availabilityValues),
|
||||
cellStyle: (params) => this.presentationStyle(params.value, availabilityValues),
|
||||
minWidth: 180,
|
||||
},
|
||||
{ field: 'expectedDeliveryDate', headerName: 'Erwartet', editable: this.canEdit },
|
||||
{
|
||||
field: 'expectedDeliveryDate',
|
||||
headerName: 'Erwartet',
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
},
|
||||
{
|
||||
field: 'favorite',
|
||||
headerName: 'Favorit',
|
||||
@@ -696,7 +719,7 @@ export class FurnitureGridComponent implements OnChanges {
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
editable: this.canEdit,
|
||||
editable: this.canEdit && !this.dialogEditing,
|
||||
cellEditor: 'agSelectCellEditor',
|
||||
cellEditorParams: { values: Object.keys(optionStatuses) },
|
||||
valueFormatter: (params) => this.present(params.value, optionStatuses),
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
@switch (section) {
|
||||
@case ('furniture') {
|
||||
<span class="eyebrow">Einrichtung</span>
|
||||
<h2>Möbel</h2>
|
||||
<p>Produkte, Preise und Lieferstatus an einem Ort.</p>
|
||||
}
|
||||
@case ('requirements') {
|
||||
<span class="eyebrow">Planung</span>
|
||||
<h2>Bedarfe</h2>
|
||||
<p>Was wird in welchem Raum benötigt?</p>
|
||||
}
|
||||
@case ('scenarios') {
|
||||
<span class="eyebrow">Varianten</span>
|
||||
<h2>Szenarien</h2>
|
||||
<p>Möbelauswahlen als klare Einrichtungsvarianten vergleichen.</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (canEdit) {
|
||||
@switch (section) {
|
||||
@case ('furniture') {
|
||||
<button
|
||||
class="ui-button ui-button--primary ui-button--mobile-full"
|
||||
type="button"
|
||||
(click)="startNewOption()"
|
||||
>
|
||||
Möbel anlegen
|
||||
</button>
|
||||
}
|
||||
@case ('requirements') {
|
||||
<button
|
||||
class="ui-button ui-button--primary ui-button--mobile-full"
|
||||
type="button"
|
||||
(click)="newRequirement()"
|
||||
>
|
||||
Bedarf anlegen
|
||||
</button>
|
||||
}
|
||||
@case ('scenarios') {
|
||||
<button
|
||||
class="ui-button ui-button--primary ui-button--mobile-full"
|
||||
type="button"
|
||||
(click)="newScenario()"
|
||||
>
|
||||
Szenario anlegen
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (error()) {
|
||||
<p class="error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (loading()) {
|
||||
<p class="loading" role="status">Daten werden geladen …</p>
|
||||
}
|
||||
|
||||
@if (section === 'furniture') {
|
||||
<app-furniture-grid
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms"
|
||||
[canEdit]="canEdit"
|
||||
[scenarios]="scenarios()"
|
||||
fixedView="options"
|
||||
[dialogEditing]="true"
|
||||
(editOption)="editOption($event)"
|
||||
(dataChanged)="load()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (section === 'requirements') {
|
||||
<app-furniture-grid
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms"
|
||||
[canEdit]="canEdit"
|
||||
[scenarios]="scenarios()"
|
||||
fixedView="requirements"
|
||||
[dialogEditing]="true"
|
||||
(editRequirement)="editRequirement($event)"
|
||||
(addOption)="newOption($event)"
|
||||
(dataChanged)="load()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (section === 'scenarios') {
|
||||
@if (scenarios().length) {
|
||||
<div class="scenario-cards" aria-label="Szenarien im Überblick">
|
||||
@for (scenario of scenarios(); track scenario.id) {
|
||||
<article class="ui-card scenario-card">
|
||||
<div>
|
||||
<span class="scenario-label">{{ scenario.isDefault ? 'Standard' : 'Szenario' }}</span>
|
||||
<h3>{{ scenario.name }}</h3>
|
||||
</div>
|
||||
<strong>{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong>
|
||||
<p>{{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen</p>
|
||||
@if (canEdit) {
|
||||
<button
|
||||
class="ui-button ui-button--ghost"
|
||||
type="button"
|
||||
(click)="editAssignment(scenario.id)"
|
||||
>
|
||||
Auswahl bearbeiten
|
||||
</button>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
<app-furniture-grid
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms"
|
||||
[canEdit]="canEdit"
|
||||
[scenarios]="scenarios()"
|
||||
fixedView="scenarios"
|
||||
[dialogEditing]="true"
|
||||
(assignScenario)="editAssignment($event.scenarioId, $event.requirement)"
|
||||
(dataChanged)="load()"
|
||||
/>
|
||||
} @else if (!loading()) {
|
||||
<section class="ui-card empty-state">
|
||||
<h3>Noch kein Szenario</h3>
|
||||
<p>Legen Sie eine Variante an, um Möbel sinnvoll zusammenzustellen.</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<dialog #requirementDialog class="editor-dialog" (cancel)="closeDialog('requirement')">
|
||||
<form [formGroup]="requirementForm" (ngSubmit)="saveRequirement()">
|
||||
<header>
|
||||
<span class="eyebrow">Bedarf</span>
|
||||
<h3>{{ editingRequirement() ? 'Bedarf bearbeiten' : 'Bedarf anlegen' }}</h3>
|
||||
</header>
|
||||
<div class="form-grid">
|
||||
<label class="wide"
|
||||
>Bezeichnung *<input formControlName="name" placeholder="z. B. Sofa"
|
||||
/></label>
|
||||
<label
|
||||
>Raum<select formControlName="roomId">
|
||||
<option value="">Ohne Raum</option>
|
||||
@for (room of rooms; track room.id) {
|
||||
<option [value]="room.id">{{ room.name }}</option>
|
||||
}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Kategorie<select formControlName="category">
|
||||
<option value="seating">Sitzmöbel</option>
|
||||
<option value="tables">Tische</option>
|
||||
<option value="chairs">Stühle</option>
|
||||
<option value="beds">Betten</option>
|
||||
<option value="cabinets">Schränke</option>
|
||||
<option value="lighting">Beleuchtung</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Priorität<select formControlName="priority">
|
||||
<option value="optional">Optional</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="essential">Unverzichtbar</option>
|
||||
</select></label
|
||||
>
|
||||
<label>Menge<input type="number" min="1" formControlName="requiredQuantity" /></label>
|
||||
<label
|
||||
>Maximalbudget<input type="number" min="0" step="0.01" formControlName="maximumBudget"
|
||||
/></label>
|
||||
<label
|
||||
>Status<select formControlName="status">
|
||||
<option value="identified">Bedarf erkannt</option>
|
||||
<option value="research">Recherche</option>
|
||||
<option value="decision_open">Entscheidung offen</option>
|
||||
<option value="selected">Ausgewählt</option>
|
||||
<option value="ordered">Bestellt</option>
|
||||
<option value="delivered">Geliefert</option>
|
||||
<option value="assembled">Aufgebaut</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="wide"
|
||||
>Beschreibung<textarea rows="3" formControlName="description"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('requirement')">
|
||||
Abbrechen</button
|
||||
><button class="ui-button ui-button--primary" type="submit" [disabled]="saving()">
|
||||
Speichern
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
#optionTargetDialog
|
||||
class="editor-dialog editor-dialog--small"
|
||||
(cancel)="closeDialog('target')"
|
||||
>
|
||||
<form [formGroup]="optionTargetForm" (ngSubmit)="chooseRequirement()">
|
||||
<header>
|
||||
<span class="eyebrow">Zuordnung</span>
|
||||
<h3>Für welchen Bedarf?</h3>
|
||||
<p>Jedes Möbel wird einem Bedarf zugeordnet.</p>
|
||||
</header>
|
||||
<label
|
||||
>Bedarf *<select formControlName="requirementId">
|
||||
<option value="">Bitte wählen</option>
|
||||
@for (requirement of requirements(); track requirement.id) {
|
||||
<option [value]="requirement.id">
|
||||
{{ requirement.name }} · {{ roomName(requirement.roomId) }}
|
||||
</option>
|
||||
}
|
||||
</select></label
|
||||
>
|
||||
<footer>
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('target')">
|
||||
Abbrechen</button
|
||||
><button class="ui-button ui-button--primary" type="submit">Weiter</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog #optionDialog class="editor-dialog editor-dialog--wide" (cancel)="closeDialog('option')">
|
||||
<form [formGroup]="optionForm" (ngSubmit)="saveOption()">
|
||||
<header>
|
||||
<span class="eyebrow">Möbel</span>
|
||||
<h3>{{ editingOption() ? 'Möbel bearbeiten' : 'Möbel anlegen' }}</h3>
|
||||
<p>{{ optionRequirement()?.name }}</p>
|
||||
</header>
|
||||
<div class="form-grid">
|
||||
<label class="wide"
|
||||
>Name *<input formControlName="name" placeholder="z. B. Ecksofa Oslo"
|
||||
/></label>
|
||||
<label>Hersteller<input formControlName="manufacturer" /></label
|
||||
><label>Modell<input formControlName="model" /></label>
|
||||
<label>Händler<input formControlName="retailer" /></label
|
||||
><label>Artikelnummer<input formControlName="articleNumber" /></label>
|
||||
<label
|
||||
>Einzelpreis<input type="number" min="0" step="0.01" formControlName="unitPrice" /></label
|
||||
><label>Menge<input type="number" min="1" formControlName="quantity" /></label>
|
||||
<output class="total wide"
|
||||
>Gesamtpreis
|
||||
<strong>{{ totalPrice() | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</strong></output
|
||||
>
|
||||
<label
|
||||
>Verfügbarkeit<select formControlName="availability">
|
||||
<option value="unknown">Unbekannt</option>
|
||||
<option value="available">Verfügbar</option>
|
||||
<option value="limited">Begrenzt</option>
|
||||
<option value="unavailable">Nicht verfügbar</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Status<select formControlName="status">
|
||||
<option value="idea">Idee</option>
|
||||
<option value="reviewing">In Prüfung</option>
|
||||
<option value="favorite">Favorit</option>
|
||||
<option value="selected">Ausgewählt</option>
|
||||
<option value="ordered">Bestellt</option>
|
||||
<option value="delivered">Geliefert</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="check"><input type="checkbox" formControlName="favorite" /> Favorit</label
|
||||
><label class="check"
|
||||
><input type="checkbox" formControlName="existingItem" /> Bereits vorhanden</label
|
||||
>
|
||||
<details class="wide">
|
||||
<summary>Weitere Angaben</summary>
|
||||
<div class="form-grid details-grid">
|
||||
<label>Produkt-URL<input type="url" formControlName="productUrl" /></label
|
||||
><label>Lieferdatum<input type="date" formControlName="expectedDeliveryDate" /></label>
|
||||
<label
|
||||
>Versand<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
formControlName="shippingCost" /></label
|
||||
><label
|
||||
>Zusatzkosten<input type="number" min="0" step="0.01" formControlName="additionalCost"
|
||||
/></label>
|
||||
<label>Rabatt<input type="number" min="0" step="0.01" formControlName="discount" /></label
|
||||
><label
|
||||
>Lieferzeit in Tagen<input type="number" min="0" formControlName="deliveryDays"
|
||||
/></label>
|
||||
<label>Breite (cm)<input type="number" min="0" formControlName="width" /></label
|
||||
><label>Höhe (cm)<input type="number" min="0" formControlName="height" /></label
|
||||
><label>Tiefe (cm)<input type="number" min="0" formControlName="depth" /></label>
|
||||
<label>Farbe<input formControlName="color" /></label
|
||||
><label>Material<input formControlName="material" /></label>
|
||||
<label class="wide"
|
||||
>Beschreibung<textarea rows="3" formControlName="description"></textarea></label
|
||||
><label class="wide">Notizen<textarea rows="3" formControlName="notes"></textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('option')">
|
||||
Abbrechen</button
|
||||
><button class="ui-button ui-button--primary" type="submit" [disabled]="saving()">
|
||||
Speichern
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
#scenarioDialog
|
||||
class="editor-dialog editor-dialog--small"
|
||||
(cancel)="closeDialog('scenario')"
|
||||
>
|
||||
<form [formGroup]="scenarioForm" (ngSubmit)="saveScenario()">
|
||||
<header>
|
||||
<span class="eyebrow">Variante</span>
|
||||
<h3>Szenario anlegen</h3>
|
||||
<p>Eine Vorauswahl kann später jederzeit angepasst werden.</p>
|
||||
</header>
|
||||
<label>Name *<input formControlName="name" placeholder="z. B. Wunschlösung" /></label>
|
||||
<label
|
||||
>Vorauswahl<select formControlName="automaticSelection">
|
||||
<option value="budget">Günstigste Möbel</option>
|
||||
<option value="preferred">Favoriten</option>
|
||||
<option value="premium">Hochwertigste Möbel</option>
|
||||
<option value="existing">Vorhandene Möbel</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="check"
|
||||
><input type="checkbox" formControlName="isDefault" /> Als Standard verwenden</label
|
||||
>
|
||||
<footer>
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('scenario')">
|
||||
Abbrechen</button
|
||||
><button class="ui-button ui-button--primary" type="submit" [disabled]="saving()">
|
||||
Anlegen
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog #assignmentDialog class="editor-dialog" (cancel)="closeDialog('assignment')">
|
||||
<form [formGroup]="assignmentForm" (ngSubmit)="saveAssignment()">
|
||||
<header>
|
||||
<span class="eyebrow">Szenario</span>
|
||||
<h3>Auswahl bearbeiten</h3>
|
||||
<p>Wählen Sie für einen Bedarf das passende Möbel.</p>
|
||||
</header>
|
||||
<div class="form-grid">
|
||||
<label
|
||||
>Szenario<select formControlName="scenarioId">
|
||||
@for (scenario of scenarios(); track scenario.id) {
|
||||
<option [value]="scenario.id">{{ scenario.name }}</option>
|
||||
}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Bedarf<select formControlName="requirementId" (change)="assignmentRequirementChanged()">
|
||||
@for (requirement of assignableRequirements(); track requirement.id) {
|
||||
<option [value]="requirement.id">{{ requirement.name }}</option>
|
||||
}
|
||||
</select></label
|
||||
>
|
||||
<fieldset class="wide">
|
||||
<legend>Möbel auswählen</legend>
|
||||
<div class="option-list">
|
||||
@for (option of assignmentOptions(); track option.id) {
|
||||
<label class="option-choice"
|
||||
><input type="radio" formControlName="optionId" [value]="option.id" /><span
|
||||
><strong>{{ option.name }}</strong
|
||||
><small>{{ option.totalPrice | currency: 'EUR' }}</small></span
|
||||
></label
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="closeDialog('assignment')">
|
||||
Abbrechen</button
|
||||
><button class="ui-button ui-button--primary" type="submit" [disabled]="saving()">
|
||||
Auswahl speichern
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -0,0 +1,252 @@
|
||||
:host {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
|
||||
.page-intro > div {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.page-intro p,
|
||||
.loading {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.scenario-label {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scenario-cards {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(15rem, 82vw);
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-block-end: var(--space-2);
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.scenario-card > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.scenario-card > strong {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.scenario-card p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-subtle);
|
||||
border-left: 0.25rem solid var(--color-danger);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
width: min(42rem, calc(100vw - 2 * var(--space-3)));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 2 * var(--space-3));
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.editor-dialog--wide {
|
||||
width: min(52rem, calc(100vw - 2 * var(--space-3)));
|
||||
}
|
||||
|
||||
.editor-dialog--small {
|
||||
width: min(30rem, calc(100vw - 2 * var(--space-3)));
|
||||
}
|
||||
|
||||
.editor-dialog::backdrop {
|
||||
background: color-mix(in srgb, var(--color-text-primary) 52%, transparent);
|
||||
}
|
||||
|
||||
.editor-dialog form {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.editor-dialog header {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.editor-dialog header p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.details-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
label.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
details {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-block-start: var(--space-4);
|
||||
}
|
||||
|
||||
summary {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
margin-block-start: var(--space-4);
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-primary-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.total strong {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.option-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.option-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.option-choice input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.option-choice span {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.editor-dialog footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding-block-start: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 36rem) {
|
||||
.page-intro {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.details-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.scenario-cards {
|
||||
grid-auto-flow: initial;
|
||||
grid-auto-columns: initial;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 35.99rem) {
|
||||
.editor-dialog form {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.editor-dialog footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.editor-dialog footer .ui-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { Component, Input, ViewChild, computed, inject, signal } from '@angular/core';
|
||||
import type { ElementRef, OnChanges } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { ToastService } from '../../shared/ui';
|
||||
import { FurnitureGridComponent } from './furniture-grid.component';
|
||||
import type {
|
||||
FurnitureOption,
|
||||
FurnitureRequirement,
|
||||
FurnitureScenario,
|
||||
Room,
|
||||
} from './hauspilot-api.service';
|
||||
import { HauspilotApiService } from './hauspilot-api.service';
|
||||
import { conflictMessage } from './project-workspace.helpers';
|
||||
|
||||
export type FurnitureSection = 'furniture' | 'requirements' | 'scenarios';
|
||||
|
||||
@Component({
|
||||
selector: 'app-furniture-section',
|
||||
standalone: true,
|
||||
imports: [CurrencyPipe, FurnitureGridComponent, ReactiveFormsModule],
|
||||
templateUrl: './furniture-section.component.html',
|
||||
styleUrl: './furniture-section.component.scss',
|
||||
})
|
||||
export class FurnitureSectionComponent implements OnChanges {
|
||||
@Input({ required: true }) projectId = '';
|
||||
@Input({ required: true }) section: FurnitureSection = 'furniture';
|
||||
@Input() rooms: Room[] = [];
|
||||
@Input() canEdit = false;
|
||||
|
||||
@ViewChild('requirementDialog') private requirementDialog?: ElementRef<HTMLDialogElement>;
|
||||
@ViewChild('optionTargetDialog') private optionTargetDialog?: ElementRef<HTMLDialogElement>;
|
||||
@ViewChild('optionDialog') private optionDialog?: ElementRef<HTMLDialogElement>;
|
||||
@ViewChild('scenarioDialog') private scenarioDialog?: ElementRef<HTMLDialogElement>;
|
||||
@ViewChild('assignmentDialog') private assignmentDialog?: ElementRef<HTMLDialogElement>;
|
||||
|
||||
private readonly api = inject(HauspilotApiService);
|
||||
private readonly toasts = inject(ToastService);
|
||||
readonly requirements = signal<FurnitureRequirement[]>([]);
|
||||
readonly scenarios = signal<FurnitureScenario[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly editingRequirement = signal<FurnitureRequirement | null>(null);
|
||||
readonly editingOption = signal<FurnitureOption | null>(null);
|
||||
readonly optionRequirement = signal<FurnitureRequirement | null>(null);
|
||||
readonly assignableRequirements = computed(() =>
|
||||
this.requirements().filter((requirement) => this.optionsFor(requirement).length > 0),
|
||||
);
|
||||
|
||||
readonly requirementForm = new FormGroup({
|
||||
name: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
roomId: new FormControl('', { nonNullable: true }),
|
||||
category: new FormControl('other', { nonNullable: true }),
|
||||
priority: new FormControl('normal', { nonNullable: true }),
|
||||
requiredQuantity: new FormControl(1, {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.min(1)(control)],
|
||||
}),
|
||||
maximumBudget: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
status: new FormControl('identified', { nonNullable: true }),
|
||||
description: new FormControl('', { nonNullable: true }),
|
||||
sortOrder: new FormControl(0, { nonNullable: true }),
|
||||
});
|
||||
readonly optionTargetForm = new FormGroup({
|
||||
requirementId: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
});
|
||||
readonly optionForm = new FormGroup({
|
||||
name: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
manufacturer: new FormControl('', { nonNullable: true }),
|
||||
model: new FormControl('', { nonNullable: true }),
|
||||
retailer: new FormControl('', { nonNullable: true }),
|
||||
productUrl: new FormControl('', { nonNullable: true }),
|
||||
articleNumber: new FormControl('', { nonNullable: true }),
|
||||
unitPrice: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
quantity: new FormControl(1, {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.min(1)(control)],
|
||||
}),
|
||||
shippingCost: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
additionalCost: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
discount: new FormControl<number | null>(null, {
|
||||
validators: [(control) => Validators.min(0)(control)],
|
||||
}),
|
||||
availability: new FormControl('unknown', { nonNullable: true }),
|
||||
status: new FormControl('idea', { nonNullable: true }),
|
||||
favorite: new FormControl(false, { nonNullable: true }),
|
||||
existingItem: new FormControl(false, { nonNullable: true }),
|
||||
width: new FormControl<number | null>(null),
|
||||
height: new FormControl<number | null>(null),
|
||||
depth: new FormControl<number | null>(null),
|
||||
color: new FormControl('', { nonNullable: true }),
|
||||
material: new FormControl('', { nonNullable: true }),
|
||||
deliveryDays: new FormControl<number | null>(null),
|
||||
expectedDeliveryDate: new FormControl('', { nonNullable: true }),
|
||||
description: new FormControl('', { nonNullable: true }),
|
||||
notes: new FormControl('', { nonNullable: true }),
|
||||
movingCost: new FormControl(0, { nonNullable: true }),
|
||||
refurbishmentCost: new FormControl(0, { nonNullable: true }),
|
||||
});
|
||||
readonly scenarioForm = new FormGroup({
|
||||
name: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
automaticSelection: new FormControl('budget', { nonNullable: true }),
|
||||
isDefault: new FormControl(false, { nonNullable: true }),
|
||||
});
|
||||
readonly assignmentForm = new FormGroup({
|
||||
scenarioId: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
requirementId: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
optionId: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
});
|
||||
|
||||
readonly totalPrice = computed(() => {
|
||||
const value = this.optionForm.getRawValue();
|
||||
const purchase = value.existingItem ? 0 : (value.unitPrice ?? 0) * value.quantity;
|
||||
return Math.max(
|
||||
0,
|
||||
purchase +
|
||||
(value.shippingCost ?? 0) +
|
||||
(value.additionalCost ?? 0) +
|
||||
value.movingCost +
|
||||
value.refurbishmentCost -
|
||||
(value.discount ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (this.projectId) this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
forkJoin({
|
||||
requirements: this.api.furnitureRequirements(this.projectId, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sortBy: 'sortOrder',
|
||||
sortDirection: 'ASC',
|
||||
}),
|
||||
scenarios: this.api.furnitureScenarios(this.projectId),
|
||||
}).subscribe({
|
||||
next: ({ requirements, scenarios }) => {
|
||||
this.requirements.set(requirements.items);
|
||||
this.scenarios.set(scenarios);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (error: unknown) => this.fail(error),
|
||||
});
|
||||
}
|
||||
|
||||
newRequirement(): void {
|
||||
this.editingRequirement.set(null);
|
||||
this.requirementForm.reset({
|
||||
name: '',
|
||||
roomId: '',
|
||||
category: 'other',
|
||||
priority: 'normal',
|
||||
requiredQuantity: 1,
|
||||
maximumBudget: null,
|
||||
status: 'identified',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
});
|
||||
this.open(this.requirementDialog);
|
||||
}
|
||||
|
||||
editRequirement(requirement: FurnitureRequirement): void {
|
||||
this.editingRequirement.set(requirement);
|
||||
this.requirementForm.reset({
|
||||
name: requirement.name,
|
||||
roomId: requirement.roomId ?? '',
|
||||
category: requirement.category,
|
||||
priority: requirement.priority,
|
||||
requiredQuantity: requirement.requiredQuantity,
|
||||
maximumBudget: requirement.maximumBudget === null ? null : Number(requirement.maximumBudget),
|
||||
status: requirement.status,
|
||||
description: requirement.description ?? '',
|
||||
sortOrder: requirement.sortOrder,
|
||||
});
|
||||
this.open(this.requirementDialog);
|
||||
}
|
||||
|
||||
saveRequirement(): void {
|
||||
if (this.requirementForm.invalid) return this.invalid(this.requirementForm);
|
||||
const existing = this.editingRequirement();
|
||||
const value = this.requirementForm.getRawValue();
|
||||
const request = existing
|
||||
? this.api.updateFurnitureRequirement(this.projectId, existing, {
|
||||
...value,
|
||||
roomId: value.roomId || undefined,
|
||||
})
|
||||
: this.api.createFurnitureRequirement(this.projectId, {
|
||||
...value,
|
||||
roomId: value.roomId || undefined,
|
||||
});
|
||||
this.save(request, () => this.close(this.requirementDialog));
|
||||
}
|
||||
|
||||
startNewOption(): void {
|
||||
if (!this.requirements().length) {
|
||||
this.toasts.show({
|
||||
title: 'Bedarf fehlt',
|
||||
message: 'Legen Sie zuerst einen Bedarf an.',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.optionTargetForm.reset({
|
||||
requirementId: this.requirements().length === 1 ? (this.requirements()[0]?.id ?? '') : '',
|
||||
});
|
||||
this.open(this.optionTargetDialog);
|
||||
}
|
||||
|
||||
chooseRequirement(): void {
|
||||
if (this.optionTargetForm.invalid) return this.invalid(this.optionTargetForm);
|
||||
const requirement = this.requirements().find(
|
||||
({ id }) => id === this.optionTargetForm.controls.requirementId.value,
|
||||
);
|
||||
if (!requirement) return;
|
||||
this.close(this.optionTargetDialog);
|
||||
this.newOption(requirement);
|
||||
}
|
||||
|
||||
newOption(requirement: FurnitureRequirement): void {
|
||||
this.optionRequirement.set(requirement);
|
||||
this.editingOption.set(null);
|
||||
this.optionForm.reset({
|
||||
name: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
retailer: '',
|
||||
productUrl: '',
|
||||
articleNumber: '',
|
||||
unitPrice: null,
|
||||
quantity: requirement.requiredQuantity,
|
||||
shippingCost: null,
|
||||
additionalCost: null,
|
||||
discount: null,
|
||||
availability: 'unknown',
|
||||
status: 'idea',
|
||||
favorite: false,
|
||||
existingItem: false,
|
||||
width: null,
|
||||
height: null,
|
||||
depth: null,
|
||||
color: '',
|
||||
material: '',
|
||||
deliveryDays: null,
|
||||
expectedDeliveryDate: '',
|
||||
description: '',
|
||||
notes: '',
|
||||
movingCost: 0,
|
||||
refurbishmentCost: 0,
|
||||
});
|
||||
this.open(this.optionDialog);
|
||||
}
|
||||
|
||||
editOption(option: FurnitureOption): void {
|
||||
this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({
|
||||
next: (requirement) => {
|
||||
this.optionRequirement.set(requirement);
|
||||
this.editingOption.set(option);
|
||||
this.optionForm.reset({
|
||||
name: option.name,
|
||||
manufacturer: option.manufacturer ?? '',
|
||||
model: option.model ?? '',
|
||||
retailer: option.retailer ?? '',
|
||||
productUrl: option.productUrl ?? '',
|
||||
articleNumber: option.articleNumber ?? '',
|
||||
unitPrice: Number(option.unitPrice),
|
||||
quantity: option.quantity,
|
||||
shippingCost: Number(option.shippingCost),
|
||||
additionalCost: Number(option.additionalCost),
|
||||
discount: Number(option.discount),
|
||||
availability: option.availability,
|
||||
status: option.status,
|
||||
favorite: option.favorite,
|
||||
existingItem: option.existingItem,
|
||||
width: option.width === null ? null : Number(option.width),
|
||||
height: option.height === null ? null : Number(option.height),
|
||||
depth: option.depth === null ? null : Number(option.depth),
|
||||
color: option.color ?? '',
|
||||
material: option.material ?? '',
|
||||
deliveryDays: option.deliveryDays,
|
||||
expectedDeliveryDate: option.expectedDeliveryDate ?? '',
|
||||
description: option.description ?? '',
|
||||
notes: option.notes ?? '',
|
||||
movingCost: Number(option.movingCost),
|
||||
refurbishmentCost: Number(option.refurbishmentCost),
|
||||
});
|
||||
this.open(this.optionDialog);
|
||||
},
|
||||
error: (error: unknown) => this.fail(error),
|
||||
});
|
||||
}
|
||||
|
||||
saveOption(): void {
|
||||
if (this.optionForm.invalid) return this.invalid(this.optionForm);
|
||||
const requirement = this.optionRequirement();
|
||||
if (!requirement) return;
|
||||
const value = this.optionForm.getRawValue();
|
||||
const body = {
|
||||
...value,
|
||||
currency: 'EUR',
|
||||
productUrl: value.productUrl || undefined,
|
||||
expectedDeliveryDate: value.expectedDeliveryDate || undefined,
|
||||
};
|
||||
const existing = this.editingOption();
|
||||
const request = existing
|
||||
? this.api.updateFurnitureOption(this.projectId, existing, body)
|
||||
: this.api.createFurnitureOption(this.projectId, requirement.id, body);
|
||||
this.save(request, () => this.close(this.optionDialog));
|
||||
}
|
||||
|
||||
newScenario(): void {
|
||||
this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false });
|
||||
this.open(this.scenarioDialog);
|
||||
}
|
||||
|
||||
saveScenario(): void {
|
||||
if (this.scenarioForm.invalid) return this.invalid(this.scenarioForm);
|
||||
const value = this.scenarioForm.getRawValue();
|
||||
this.save(
|
||||
this.api.createFurnitureScenario(this.projectId, {
|
||||
name: value.name,
|
||||
type: value.automaticSelection,
|
||||
status: 'draft',
|
||||
automaticSelection: value.automaticSelection,
|
||||
isDefault: value.isDefault,
|
||||
}),
|
||||
() => this.close(this.scenarioDialog),
|
||||
);
|
||||
}
|
||||
|
||||
editAssignment(scenarioId = '', requirement?: FurnitureRequirement): void {
|
||||
const scenario = this.scenarios().find(({ id }) => id === scenarioId) ?? this.scenarios()[0];
|
||||
const target = requirement ?? this.assignableRequirements()[0];
|
||||
const selection = scenario?.selections.find(
|
||||
({ requirementId }) => requirementId === target?.id,
|
||||
);
|
||||
this.assignmentForm.reset({
|
||||
scenarioId: scenario?.id ?? '',
|
||||
requirementId: target?.id ?? '',
|
||||
optionId: selection?.optionId ?? '',
|
||||
});
|
||||
this.open(this.assignmentDialog);
|
||||
}
|
||||
|
||||
assignmentRequirementChanged(): void {
|
||||
const scenario = this.scenarios().find(
|
||||
({ id }) => id === this.assignmentForm.controls.scenarioId.value,
|
||||
);
|
||||
const selection = scenario?.selections.find(
|
||||
({ requirementId }) => requirementId === this.assignmentForm.controls.requirementId.value,
|
||||
);
|
||||
this.assignmentForm.controls.optionId.setValue(selection?.optionId ?? '');
|
||||
}
|
||||
|
||||
assignmentOptions(): FurnitureOption[] {
|
||||
const requirement = this.requirements().find(
|
||||
({ id }) => id === this.assignmentForm.controls.requirementId.value,
|
||||
);
|
||||
return requirement ? this.optionsFor(requirement) : [];
|
||||
}
|
||||
|
||||
saveAssignment(): void {
|
||||
if (this.assignmentForm.invalid) return this.invalid(this.assignmentForm);
|
||||
const value = this.assignmentForm.getRawValue();
|
||||
const scenario = this.scenarios().find(({ id }) => id === value.scenarioId);
|
||||
const requirement = this.requirements().find(({ id }) => id === value.requirementId);
|
||||
const option = requirement?.options.find(({ id }) => id === value.optionId);
|
||||
if (!scenario || !requirement || !option) return;
|
||||
const selections = scenario.selections.filter(
|
||||
({ requirementId }) => requirementId !== requirement.id,
|
||||
);
|
||||
selections.push({
|
||||
requirementId: requirement.id,
|
||||
optionId: option.id,
|
||||
quantity: option.quantity,
|
||||
});
|
||||
this.save(
|
||||
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections),
|
||||
() => this.close(this.assignmentDialog),
|
||||
);
|
||||
}
|
||||
|
||||
roomName(roomId: string | null): string {
|
||||
return this.rooms.find(({ id }) => id === roomId)?.name ?? 'Ohne Raum';
|
||||
}
|
||||
|
||||
closeDialog(dialog: 'requirement' | 'target' | 'option' | 'scenario' | 'assignment'): void {
|
||||
const dialogs = {
|
||||
requirement: this.requirementDialog,
|
||||
target: this.optionTargetDialog,
|
||||
option: this.optionDialog,
|
||||
scenario: this.scenarioDialog,
|
||||
assignment: this.assignmentDialog,
|
||||
};
|
||||
this.close(dialogs[dialog]);
|
||||
}
|
||||
|
||||
private optionsFor(requirement: FurnitureRequirement): FurnitureOption[] {
|
||||
return requirement.options.filter(
|
||||
(option) =>
|
||||
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
|
||||
!['unavailable', 'discontinued'].includes(option.availability),
|
||||
);
|
||||
}
|
||||
|
||||
private open(dialog?: ElementRef<HTMLDialogElement>): void {
|
||||
queueMicrotask(() => dialog?.nativeElement.showModal());
|
||||
}
|
||||
|
||||
private close(dialog?: ElementRef<HTMLDialogElement>): void {
|
||||
dialog?.nativeElement.close();
|
||||
this.editingRequirement.set(null);
|
||||
this.editingOption.set(null);
|
||||
this.optionRequirement.set(null);
|
||||
}
|
||||
|
||||
private invalid(form: FormGroup): void {
|
||||
form.markAllAsTouched();
|
||||
this.toasts.show({
|
||||
title: 'Angaben prüfen',
|
||||
message: 'Bitte prüfen Sie die markierten Angaben.',
|
||||
tone: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
private save<T>(request: Observable<T>, done: () => void): void {
|
||||
this.saving.set(true);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
done();
|
||||
this.load();
|
||||
},
|
||||
error: (error: unknown) => this.fail(error),
|
||||
});
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
this.saving.set(false);
|
||||
this.loading.set(false);
|
||||
const response = error as { status?: number; error?: { message?: string } };
|
||||
this.error.set(
|
||||
conflictMessage(
|
||||
response.status ?? 0,
|
||||
response.error?.message ?? 'Die Änderung konnte nicht gespeichert werden.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { HauspilotApiService } from './hauspilot-api.service';
|
||||
import { ProjectCalendarComponent } from './project-calendar.component';
|
||||
import { FurniturePlanningComponent } from './furniture-planning.component';
|
||||
import { FurnitureSectionComponent } from './furniture-section.component';
|
||||
import { conflictMessage, filterTasks } from './project-workspace.helpers';
|
||||
import type {
|
||||
BudgetCategory,
|
||||
@@ -43,7 +43,7 @@ import type {
|
||||
UiStatusBadgeComponent,
|
||||
ProjectCalendarComponent,
|
||||
UiPaginationComponent,
|
||||
FurniturePlanningComponent,
|
||||
FurnitureSectionComponent,
|
||||
],
|
||||
template: `
|
||||
@if (project(); as project) {
|
||||
@@ -639,7 +639,28 @@ import type {
|
||||
</section>
|
||||
}
|
||||
@case ('moebel') {
|
||||
<app-furniture-planning [projectId]="projectId" [rooms]="rooms()" [canEdit]="canEdit()" />
|
||||
<app-furniture-section
|
||||
section="furniture"
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms()"
|
||||
[canEdit]="canEdit()"
|
||||
/>
|
||||
}
|
||||
@case ('bedarfe') {
|
||||
<app-furniture-section
|
||||
section="requirements"
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms()"
|
||||
[canEdit]="canEdit()"
|
||||
/>
|
||||
}
|
||||
@case ('szenarien') {
|
||||
<app-furniture-section
|
||||
section="scenarios"
|
||||
[projectId]="projectId"
|
||||
[rooms]="rooms()"
|
||||
[canEdit]="canEdit()"
|
||||
/>
|
||||
}
|
||||
@case ('dokumente') {
|
||||
<div class="section-head">
|
||||
|
||||
@@ -154,9 +154,9 @@ describe('AppShellComponent', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest');
|
||||
expect(host.querySelector('.sidebar-context')?.textContent).toContain('Wohnung Budapest');
|
||||
expect(host.querySelector('.project-navigation')?.textContent).toContain('Möbel & Einrichtung');
|
||||
expect(host.querySelector('.project-navigation a.active')?.textContent).toContain(
|
||||
'Möbel & Einrichtung',
|
||||
);
|
||||
expect(host.querySelector('.project-navigation')?.textContent).toContain('Möbel');
|
||||
expect(host.querySelector('.project-navigation')?.textContent).toContain('Bedarfe');
|
||||
expect(host.querySelector('.project-navigation')?.textContent).toContain('Szenarien');
|
||||
expect(host.querySelector('.project-navigation a.active')?.textContent).toContain('Möbel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,9 @@ const projectSections = [
|
||||
{ id: 'aufgaben', label: 'Aufgaben' },
|
||||
{ id: 'zeitplan', label: 'Zeitplan' },
|
||||
{ id: 'budget', label: 'Budget' },
|
||||
{ id: 'moebel', label: 'Möbel & Einrichtung' },
|
||||
{ id: 'moebel', label: 'Möbel' },
|
||||
{ id: 'bedarfe', label: 'Bedarfe' },
|
||||
{ id: 'szenarien', label: 'Szenarien' },
|
||||
{ id: 'dokumente', label: 'Dokumente' },
|
||||
{ id: 'aktivitaeten', label: 'Aktivitäten' },
|
||||
] as const;
|
||||
|
||||
@@ -10,12 +10,22 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
color: var(--color-surface);
|
||||
background: var(--color-primary);
|
||||
transition:
|
||||
background-color var(--transition-fast) ease,
|
||||
border-color var(--transition-fast) ease,
|
||||
color var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.ui-button:not(.ui-button--secondary, .ui-button--ghost, .ui-button--danger):hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.ui-button:not(.ui-button--secondary, .ui-button--ghost, .ui-button--danger):active {
|
||||
background: var(--color-primary-active);
|
||||
}
|
||||
|
||||
.ui-button--primary {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-primary);
|
||||
|
||||
Reference in New Issue
Block a user