fracturing

This commit is contained in:
Bastian Wagner
2026-07-21 08:44:45 +02:00
parent 2328081aa3
commit c1d5e0bd0f
8 changed files with 1216 additions and 40 deletions

View File

@@ -110,6 +110,7 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
imports: [AgGridAngular, CurrencyPipe, FormsModule], imports: [AgGridAngular, CurrencyPipe, FormsModule],
template: ` template: `
<section class="grid-shell" aria-labelledby="furniture-grid-title"> <section class="grid-shell" aria-labelledby="furniture-grid-title">
@if (!fixedView) {
<div class="grid-tabs" role="tablist" aria-label="Möbelansichten"> <div class="grid-tabs" role="tablist" aria-label="Möbelansichten">
@for (entry of views; track entry.id) { @for (entry of views; track entry.id) {
<button <button
@@ -123,6 +124,7 @@ const deliveryStatuses: Record<string, GridValuePresentation> = {
</button> </button>
} }
</div> </div>
}
<div class="toolbar ui-card"> <div class="toolbar ui-card">
<label <label
>Suche >Suche
@@ -327,6 +329,8 @@ export class FurnitureGridComponent implements OnChanges {
@Input() rooms: Room[] = []; @Input() rooms: Room[] = [];
@Input() canEdit = false; @Input() canEdit = false;
@Input() scenarios: FurnitureScenario[] = []; @Input() scenarios: FurnitureScenario[] = [];
@Input() fixedView: View | null = null;
@Input() dialogEditing = false;
@Output() editRequirement = new EventEmitter<FurnitureRequirement>(); @Output() editRequirement = new EventEmitter<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>(); @Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>(); @Output() editOption = new EventEmitter<FurnitureOption>();
@@ -379,6 +383,7 @@ export class FurnitureGridComponent implements OnChanges {
.subscribe(() => this.load(1)); .subscribe(() => this.load(1));
} }
ngOnChanges() { ngOnChanges() {
if (this.fixedView) this.view.set(this.fixedView);
this.refreshColumns(); this.refreshColumns();
if (this.projectId) this.load(1); if (this.projectId) this.load(1);
} }
@@ -506,18 +511,23 @@ export class FurnitureGridComponent implements OnChanges {
field: 'roomId', field: 'roomId',
headerName: 'Raum', headerName: 'Raum',
valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '', valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: this.rooms.map((r) => r.id) }, cellEditorParams: { values: this.rooms.map((r) => r.id) },
sort: 'asc', sort: 'asc',
colId: 'room', colId: 'room',
}, },
{ field: 'category', headerName: 'Kategorie', editable: this.canEdit }, { field: 'category', headerName: 'Kategorie', editable: this.canEdit && !this.dialogEditing },
{ field: 'name', headerName: 'Möbelbedarf', editable: this.canEdit, minWidth: 180 }, {
field: 'name',
headerName: 'Möbelbedarf',
editable: this.canEdit && !this.dialogEditing,
minWidth: 180,
},
{ {
field: 'priority', field: 'priority',
headerName: 'Priorität', headerName: 'Priorität',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(requirementPriorities) }, cellEditorParams: { values: Object.keys(requirementPriorities) },
valueFormatter: (params) => this.present(params.value, requirementPriorities), valueFormatter: (params) => this.present(params.value, requirementPriorities),
@@ -527,13 +537,13 @@ export class FurnitureGridComponent implements OnChanges {
{ {
field: 'requiredQuantity', field: 'requiredQuantity',
headerName: 'Menge', headerName: 'Menge',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
type: 'numericColumn', type: 'numericColumn',
}, },
{ {
field: 'status', field: 'status',
headerName: 'Status', headerName: 'Status',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(requirementStatuses) }, cellEditorParams: { values: Object.keys(requirementStatuses) },
valueFormatter: (params) => this.present(params.value, requirementStatuses), valueFormatter: (params) => this.present(params.value, requirementStatuses),
@@ -543,7 +553,7 @@ export class FurnitureGridComponent implements OnChanges {
{ {
field: 'maximumBudget', field: 'maximumBudget',
headerName: 'Maximalbudget', headerName: 'Maximalbudget',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
valueFormatter: (p) => money(p.value), valueFormatter: (p) => money(p.value),
valueParser: (p) => parseGermanNumber(p.newValue), valueParser: (p) => parseGermanNumber(p.newValue),
}, },
@@ -644,9 +654,18 @@ export class FurnitureGridComponent implements OnChanges {
return [ return [
{ field: 'roomName', headerName: 'Raum', colId: 'room' }, { field: 'roomName', headerName: 'Raum', colId: 'room' },
{ field: 'requirementName', headerName: 'Möbelbedarf', colId: 'requirement', minWidth: 170 }, { 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: 'name',
{ field: 'retailer', headerName: 'Händler', editable: this.canEdit }, 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' }, { field: 'articleNumber', headerName: 'Artikelnummer' },
...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map( ...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map(
(field) => ({ (field) => ({
@@ -658,29 +677,33 @@ export class FurnitureGridComponent implements OnChanges {
additionalCost: 'Zusatzkosten', additionalCost: 'Zusatzkosten',
discount: 'Rabatt', discount: 'Rabatt',
}[field], }[field],
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
valueFormatter: valueFormatter:
field === 'quantity' ? undefined : (p: { value: unknown }) => money(p.value), field === 'quantity' ? undefined : (p: { value: unknown }) => money(p.value),
valueParser: (p: { newValue: unknown }) => parseGermanNumber(p.newValue), valueParser: (p: { newValue: unknown }) => parseGermanNumber(p.newValue),
}), }),
), ),
{ field: 'totalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => money(p.value) }, { field: 'totalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => money(p.value) },
{ field: 'color', headerName: 'Farbe', editable: this.canEdit }, { field: 'color', headerName: 'Farbe', editable: this.canEdit && !this.dialogEditing },
{ field: 'material', headerName: 'Material', editable: this.canEdit }, { field: 'material', headerName: 'Material', editable: this.canEdit && !this.dialogEditing },
{ field: 'width', headerName: 'Breite', editable: this.canEdit }, { field: 'width', headerName: 'Breite', editable: this.canEdit && !this.dialogEditing },
{ field: 'height', headerName: 'Höhe', editable: this.canEdit }, { field: 'height', headerName: 'Höhe', editable: this.canEdit && !this.dialogEditing },
{ field: 'depth', headerName: 'Tiefe', editable: this.canEdit }, { field: 'depth', headerName: 'Tiefe', editable: this.canEdit && !this.dialogEditing },
{ {
field: 'availability', field: 'availability',
headerName: 'Verfügbarkeit', headerName: 'Verfügbarkeit',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(availabilityValues) }, cellEditorParams: { values: Object.keys(availabilityValues) },
valueFormatter: (params) => this.present(params.value, availabilityValues), valueFormatter: (params) => this.present(params.value, availabilityValues),
cellStyle: (params) => this.presentationStyle(params.value, availabilityValues), cellStyle: (params) => this.presentationStyle(params.value, availabilityValues),
minWidth: 180, minWidth: 180,
}, },
{ field: 'expectedDeliveryDate', headerName: 'Erwartet', editable: this.canEdit }, {
field: 'expectedDeliveryDate',
headerName: 'Erwartet',
editable: this.canEdit && !this.dialogEditing,
},
{ {
field: 'favorite', field: 'favorite',
headerName: 'Favorit', headerName: 'Favorit',
@@ -696,7 +719,7 @@ export class FurnitureGridComponent implements OnChanges {
{ {
field: 'status', field: 'status',
headerName: 'Status', headerName: 'Status',
editable: this.canEdit, editable: this.canEdit && !this.dialogEditing,
cellEditor: 'agSelectCellEditor', cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(optionStatuses) }, cellEditorParams: { values: Object.keys(optionStatuses) },
valueFormatter: (params) => this.present(params.value, optionStatuses), valueFormatter: (params) => this.present(params.value, optionStatuses),

View File

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

View File

@@ -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%;
}
}

View File

@@ -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.',
),
);
}
}

View File

@@ -15,7 +15,7 @@ import {
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
import { HauspilotApiService } from './hauspilot-api.service'; import { HauspilotApiService } from './hauspilot-api.service';
import { ProjectCalendarComponent } from './project-calendar.component'; 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 { conflictMessage, filterTasks } from './project-workspace.helpers';
import type { import type {
BudgetCategory, BudgetCategory,
@@ -43,7 +43,7 @@ import type {
UiStatusBadgeComponent, UiStatusBadgeComponent,
ProjectCalendarComponent, ProjectCalendarComponent,
UiPaginationComponent, UiPaginationComponent,
FurniturePlanningComponent, FurnitureSectionComponent,
], ],
template: ` template: `
@if (project(); as project) { @if (project(); as project) {
@@ -639,7 +639,28 @@ import type {
</section> </section>
} }
@case ('moebel') { @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') { @case ('dokumente') {
<div class="section-head"> <div class="section-head">

View File

@@ -154,9 +154,9 @@ describe('AppShellComponent', () => {
const host = fixture.nativeElement as HTMLElement; const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest'); expect(host.querySelector('.project-tab')?.textContent).toContain('Wohnung Budapest');
expect(host.querySelector('.sidebar-context')?.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')?.textContent).toContain('Möbel');
expect(host.querySelector('.project-navigation a.active')?.textContent).toContain( expect(host.querySelector('.project-navigation')?.textContent).toContain('Bedarfe');
'Möbel & Einrichtung', expect(host.querySelector('.project-navigation')?.textContent).toContain('Szenarien');
); expect(host.querySelector('.project-navigation a.active')?.textContent).toContain('Möbel');
}); });
}); });

View File

@@ -26,7 +26,9 @@ const projectSections = [
{ id: 'aufgaben', label: 'Aufgaben' }, { id: 'aufgaben', label: 'Aufgaben' },
{ id: 'zeitplan', label: 'Zeitplan' }, { id: 'zeitplan', label: 'Zeitplan' },
{ id: 'budget', label: 'Budget' }, { 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: 'dokumente', label: 'Dokumente' },
{ id: 'aktivitaeten', label: 'Aktivitäten' }, { id: 'aktivitaeten', label: 'Aktivitäten' },
] as const; ] as const;

View File

@@ -10,12 +10,22 @@
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
line-height: 1; line-height: 1;
text-decoration: none; text-decoration: none;
color: var(--color-surface);
background: var(--color-primary);
transition: transition:
background-color var(--transition-fast) ease, background-color var(--transition-fast) ease,
border-color var(--transition-fast) ease, border-color var(--transition-fast) ease,
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 { .ui-button--primary {
color: var(--color-surface); color: var(--color-surface);
background: var(--color-primary); background: var(--color-primary);