This commit is contained in:
Bastian Wagner
2026-07-20 09:01:36 +02:00
parent 8cf57d7878
commit e62673ac11
98 changed files with 17372 additions and 80 deletions

View File

@@ -1,6 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"endOfLine": "auto",
"overrides": [
{
"files": "*.html",

View File

@@ -18,6 +18,8 @@
"@angular/platform-browser-dynamic": "22.0.6",
"@angular/router": "22.0.6",
"@boilerplate/api-client": "1.0.0",
"ag-grid-angular": "^36.0.1",
"ag-grid-community": "^36.0.1",
"rxjs": "7.8.2",
"tslib": "2.8.1"
}

View File

@@ -1,16 +1,22 @@
import { provideBrowserGlobalErrorListeners } from '@angular/core';
import { LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import type { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { csrfInterceptor } from './core/csrf.interceptor';
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
import { titleStrategyProvider } from './core/title.strategy';
registerLocaleData(localeDe);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([csrfInterceptor])),
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])),
provideRouter(routes, withComponentInputBinding()),
titleStrategyProvider,
],

View File

@@ -13,6 +13,46 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent),
},
{
path: 'projekte',
title: 'Projekte',
canActivate: [permissionGuard],
data: { permissions: ['projects.use'] },
loadComponent: () =>
import('./features/projects/projects.page').then((m) => m.ProjectsPageComponent),
},
{
path: 'projekte/:id/:section',
title: 'HausPilot-Projekt',
canActivate: [permissionGuard],
data: { permissions: ['projects.use'] },
loadComponent: () =>
import('./features/projects/project-workspace.page').then(
(m) => m.ProjectWorkspacePageComponent,
),
},
{
path: 'projekte/:id',
title: 'Projekt',
canActivate: [permissionGuard],
data: { permissions: ['projects.use'] },
loadComponent: () =>
import('./features/projects/project-detail.page').then(
(m) => m.ProjectDetailPageComponent,
),
},
{
path: 'einladungen',
title: 'Einladungen',
loadComponent: () =>
import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent),
},
{
path: 'einladungen/:token',
title: 'Projekteinladung',
loadComponent: () =>
import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent),
},
{
path: 'profil',
title: 'Profil',

View File

@@ -7,6 +7,7 @@ const user: UserDto = {
id: 'u1',
name: 'Ada',
email: 'ada@example.test',
emailVerified: true,
active: true,
lastLoginAt: null,
settings: { tablePageSize: 20, sidebarExpanded: true },
@@ -32,4 +33,17 @@ describe('AuthService', () => {
expect(service.has('items.read')).toBe(true);
expect(service.has('users.manage')).toBe(false);
});
it('clears the current user when the server session expires', () => {
TestBed.configureTestingModule({
providers: [{ provide: ApiClientService, useValue: { me: () => of(user) } }],
});
const service = TestBed.inject(AuthService);
service.user.set(user);
service.clearSessionState();
expect(service.user()).toBeNull();
expect(service.loaded()).toBe(true);
});
});

View File

@@ -40,4 +40,9 @@ export class AuthService {
has(permission: Permission): boolean {
return this.permissions().has(permission);
}
clearSessionState(): void {
this.user.set(null);
this.loaded.set(true);
}
}

View File

@@ -0,0 +1,21 @@
import type { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const sessionExpiryInterceptor: HttpInterceptorFn = (request, next) => {
const auth = inject(AuthService);
return next(request).pipe(
catchError((error: unknown) => {
if (
typeof error === 'object' &&
error !== null &&
'status' in error &&
error.status === 401
) {
auth.clearSessionState();
}
return throwError(() => error);
}),
);
};

View File

@@ -10,6 +10,7 @@ const user: UserDto = {
id: 'u1',
name: 'Ada Lovelace',
email: 'ada@example.com',
emailVerified: true,
active: true,
lastLoginAt: '2026-07-16T08:30:00.000Z',
settings: { tablePageSize: 20, sidebarExpanded: true },

View File

@@ -0,0 +1,304 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { AuthService } from '../../core/auth.service';
import { FurnitureGridComponent } from './furniture-grid.component';
import type {
FurnitureOption,
FurnitureRequirement,
FurnitureScenario,
PageResult,
} from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service';
const requirement: FurnitureRequirement = {
id: 'requirement-1',
version: 2,
createdAt: '2026-01-01',
updatedAt: '2026-01-02',
projectId: 'project-1',
roomId: 'room-1',
name: 'Sofa',
description: null,
category: 'seating',
priority: 'high',
requiredQuantity: 1,
status: 'decision_open',
responsibleUserId: null,
maximumBudget: '2000.00',
sortOrder: 0,
options: [],
optionCount: 0,
};
const page: PageResult<FurnitureRequirement> = {
items: [requirement],
page: 1,
pageSize: 50,
totalItems: 1,
totalPages: 1,
};
describe('FurnitureGridComponent', () => {
it('uses stable database IDs and passes paging and room filters to the API', async () => {
const calls: Array<Record<string, string | number | boolean | readonly string[]>> = [];
const api = {
furnitureRequirements: (
_id: string,
query: Record<string, string | number | boolean | readonly string[]>,
) => {
calls.push(query);
return of(page);
},
furnitureProjectOptions: () => of({ ...page, items: [] }),
};
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{ provide: HauspilotApiService, useValue: api },
{ provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } },
],
}).compileComponents();
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1';
component.roomId = 'room-1';
component.load(2);
expect(component.getRowId({ data: requirement })).toBe('requirement-1');
expect(calls[0]).toMatchObject({ page: 2, pageSize: 50, roomId: 'room-1' });
});
it('debounces search requests', () => {
vi.useFakeTimers();
let calls = 0;
const api = {
furnitureRequirements: () => {
calls += 1;
return of(page);
},
furnitureProjectOptions: () => of({ ...page, items: [] }),
};
TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{ provide: HauspilotApiService, useValue: api },
{ provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } },
],
});
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1';
component.searchChanges.next('So');
vi.advanceTimersByTime(150);
component.searchChanges.next('Sofa');
vi.advanceTimersByTime(299);
expect(calls).toBe(0);
vi.advanceTimersByTime(1);
expect(calls).toBe(1);
vi.useRealTimers();
});
it('does not expose editable requirement cells to readers', async () => {
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{
provide: HauspilotApiService,
useValue: {
furnitureRequirements: () => of(page),
furnitureProjectOptions: () => of({ ...page, items: [] }),
},
},
{ provide: AuthService, useValue: { user: () => ({ id: 'reader-1' }) } },
],
}).compileComponents();
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.canEdit = false;
component.ngOnChanges();
expect(component.columns().filter((column) => column.editable === true)).toHaveLength(0);
});
it('uses select editors and readable icon labels for priority and status', async () => {
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{
provide: HauspilotApiService,
useValue: {
furnitureRequirements: () => of(page),
furnitureProjectOptions: () => of({ ...page, items: [] }),
},
},
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
],
}).compileComponents();
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.canEdit = true;
component.ngOnChanges();
const priority = component.columns().find((column) => column.field === 'priority');
const status = component.columns().find((column) => column.field === 'status');
expect(priority?.cellEditor).toBe('agSelectCellEditor');
expect(status?.cellEditor).toBe('agSelectCellEditor');
expect(priority?.cellEditorParams).toMatchObject({
values: ['optional', 'low', 'normal', 'high', 'essential'],
});
expect(status?.cellEditorParams).toMatchObject({
values: [
'identified',
'research',
'has_options',
'decision_open',
'selected',
'ordered',
'partially_delivered',
'delivered',
'assembled',
'omitted',
],
});
expect(
typeof priority?.valueFormatter === 'function'
? priority.valueFormatter({ value: 'essential' } as never)
: '',
).toBe('◆ Unverzichtbar');
expect(
typeof status?.valueFormatter === 'function'
? status.valueFormatter({ value: 'decision_open' } as never)
: '',
).toBe('? Entscheidung offen');
});
it('falls back to a supported server sort when persisted grid state is invalid', async () => {
const calls: Array<Record<string, string | number | boolean | readonly string[]>> = [];
const api = {
furnitureRequirements: (
_id: string,
query: Record<string, string | number | boolean | readonly string[]>,
) => {
calls.push(query);
return of(page);
},
furnitureProjectOptions: () => of({ ...page, items: [] }),
};
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{ provide: HauspilotApiService, useValue: api },
{ provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } },
],
}).compileComponents();
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1';
Object.defineProperty(component, 'gridApi', {
value: { getColumnState: () => [{ colId: 'optionCount', sort: 'asc' }] },
});
component.load(1);
expect(calls[0]?.['sortBy']).toBe('sortOrder');
});
it('assigns an option to a scenario through the existing selection endpoint', async () => {
const option: FurnitureOption = {
id: 'option-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
requirementId: requirement.id,
name: 'Sofa Wunsch',
manufacturer: null,
model: null,
description: null,
retailer: null,
productUrl: null,
articleNumber: null,
unitPrice: '1000.00',
originalPrice: null,
shippingCost: '0.00',
additionalCost: '0.00',
discount: '0.00',
totalPrice: '1000.00',
currency: 'EUR',
quantity: 1,
width: null,
height: null,
depth: null,
weight: null,
color: null,
material: null,
deliveryDays: null,
expectedDeliveryDate: null,
availability: 'available',
favorite: true,
currentlySelected: false,
status: 'favorite',
notes: null,
budgetCategoryId: null,
existingItem: false,
movingCost: '0.00',
refurbishmentCost: '0.00',
deliveryStatus: 'not_ordered',
deliveredQuantity: 0,
orderNumber: null,
orderedAt: null,
};
const scenario: FurnitureScenario = {
id: 'scenario-1',
version: 3,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
name: 'Wunsch',
description: null,
type: 'preferred',
status: 'active',
isDefault: true,
total: '0.00',
selectedRequirements: 0,
openRequirements: 1,
byRoom: {},
selections: [],
};
const updateSelections = vi.fn(() =>
of({
...scenario,
version: 4,
total: option.totalPrice,
selections: [
{ requirementId: requirement.id, optionId: option.id, quantity: option.quantity },
],
}),
);
await TestBed.configureTestingModule({
imports: [FurnitureGridComponent],
providers: [
provideHttpClient(),
{
provide: HauspilotApiService,
useValue: {
furnitureRequirements: () => of(page),
furnitureProjectOptions: () => of({ ...page, items: [] }),
updateFurnitureScenarioSelections: updateSelections,
},
},
{ provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } },
],
}).compileComponents();
const component = TestBed.createComponent(FurnitureGridComponent).componentInstance;
component.projectId = 'project-1';
component.canEdit = true;
component.scenarios = [scenario];
component.cellChanged({
oldValue: '',
newValue: option.id,
data: { ...requirement, options: [option] },
column: { getColId: () => `scenario:${scenario.id}` },
node: { setDataValue: vi.fn() },
} as never);
expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [
{ requirementId: requirement.id, optionId: option.id, quantity: 1 },
]);
});
});

View File

@@ -0,0 +1,975 @@
import { CurrencyPipe } from '@angular/common';
import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core';
import type { OnChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AgGridAngular } from 'ag-grid-angular';
import {
AllCommunityModule,
ModuleRegistry,
themeQuartz,
type CellClickedEvent,
type CellStyle,
type CellValueChangedEvent,
type ColDef,
type GridApi,
type GridReadyEvent,
} from 'ag-grid-community';
import { debounceTime, distinctUntilChanged, Subject, type Observable } from 'rxjs';
import { AuthService } from '../../core/auth.service';
import type {
FurnitureOption,
FurnitureRequirement,
FurnitureScenario,
PageResult,
Room,
} from './hauspilot-api.service';
import { HauspilotApiService } from './hauspilot-api.service';
import { conflictMessage } from './project-workspace.helpers';
ModuleRegistry.registerModules([AllCommunityModule]);
type View = 'requirements' | 'options' | 'orders' | 'scenarios';
type FurnitureRequirementRow = FurnitureRequirement & {
scenarioSelections?: Record<string, string>;
};
type FurnitureRow = FurnitureRequirementRow | FurnitureOption;
const money = (value: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value ?? 0));
const parseGermanNumber = (value: unknown) => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string') return null;
const normalized = value.trim().replace(/\./g, '').replace(',', '.');
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
interface GridValuePresentation {
icon: string;
label: string;
tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
}
const requirementPriorities: Record<string, GridValuePresentation> = {
optional: { icon: '○', label: 'Optional', tone: 'neutral' },
low: { icon: '↓', label: 'Niedrig', tone: 'neutral' },
normal: { icon: '●', label: 'Normal', tone: 'info' },
high: { icon: '↑', label: 'Hoch', tone: 'warning' },
essential: { icon: '◆', label: 'Unverzichtbar', tone: 'danger' },
};
const requirementStatuses: Record<string, GridValuePresentation> = {
identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' },
research: { icon: '⌕', label: 'Recherche', tone: 'info' },
has_options: { icon: '≡', label: 'Alternativen vorhanden', tone: 'info' },
decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' },
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' },
delivered: { icon: '✓', label: 'Geliefert', tone: 'success' },
assembled: { icon: '⌂', label: 'Aufgebaut', tone: 'success' },
omitted: { icon: '—', label: 'Entfällt', tone: 'neutral' },
};
const optionStatuses: Record<string, GridValuePresentation> = {
idea: { icon: '◌', label: 'Idee', tone: 'neutral' },
reviewing: { icon: '⌕', label: 'In Prüfung', tone: 'info' },
favorite: { icon: '★', label: 'Favorit', tone: 'warning' },
selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' },
rejected: { icon: '×', label: 'Abgelehnt', tone: 'neutral' },
unavailable: { icon: '!', label: 'Nicht verfügbar', tone: 'danger' },
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
delivered: { icon: '✓', label: 'Geliefert', tone: 'success' },
returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' },
archived: { icon: '—', label: 'Archiviert', tone: 'neutral' },
};
const availabilityValues: Record<string, GridValuePresentation> = {
unknown: { icon: '?', label: 'Unbekannt', tone: 'neutral' },
available: { icon: '✓', label: 'Verfügbar', tone: 'success' },
limited: { icon: '!', label: 'Begrenzt verfügbar', tone: 'warning' },
unavailable: { icon: '×', label: 'Nicht verfügbar', tone: 'danger' },
discontinued: { icon: '—', label: 'Nicht mehr erhältlich', tone: 'danger' },
};
const deliveryStatuses: Record<string, GridValuePresentation> = {
not_ordered: { icon: '○', label: 'Nicht bestellt', tone: 'neutral' },
planned: { icon: '◌', label: 'Bestellung geplant', tone: 'info' },
ordered: { icon: '▣', label: 'Bestellt', tone: 'info' },
shipped: { icon: '→', label: 'Versandt', tone: 'info' },
partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' },
delivered: { icon: '✓', label: 'Geliefert', tone: 'success' },
delayed: { icon: '!', label: 'Lieferverzögerung', tone: 'danger' },
cancelled: { icon: '×', label: 'Storniert', tone: 'neutral' },
returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' },
};
@Component({
selector: 'app-furniture-grid',
standalone: true,
imports: [AgGridAngular, CurrencyPipe, FormsModule],
template: `
<section class="grid-shell" aria-labelledby="furniture-grid-title">
<div class="grid-tabs" role="tablist" aria-label="Möbelansichten">
@for (entry of views; track entry.id) {
<button
type="button"
role="tab"
[attr.aria-selected]="view() === entry.id"
[class.active]="view() === entry.id"
(click)="setView(entry.id)"
>
{{ entry.label }}
</button>
}
</div>
<div class="toolbar ui-card">
<label
>Suche
<input
[(ngModel)]="search"
(ngModelChange)="searchChanges.next($event)"
placeholder="Möbel, Hersteller oder Händler"
/></label>
<label
>Raum
<select [(ngModel)]="roomId" (ngModelChange)="load(1)">
<option value="">Alle Räume</option>
@for (room of rooms; track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
@if (view() === 'requirements') {
<label
><input type="checkbox" [(ngModel)]="openOnly" (ngModelChange)="load(1)" /> Nur offene
Entscheidungen</label
>
<label
><input type="checkbox" [(ngModel)]="overBudget" (ngModelChange)="load(1)" /> Nur über
Budget</label
>
}
@if (view() === 'options' || view() === 'orders') {
<label
><input type="checkbox" [(ngModel)]="favoriteOnly" (ngModelChange)="load(1)" /> Nur
Favoriten</label
>
<label
><input type="checkbox" [(ngModel)]="delayedOnly" (ngModelChange)="load(1)" /> Nur
verspätet</label
>
}
<button type="button" class="ui-button ui-button--ghost" (click)="exportCsv()">
CSV exportieren
</button>
</div>
@if (error()) {
<p class="row-error" role="alert">{{ error() }}</p>
}
@if (savingRow()) {
<p class="save-state" role="status">Änderung wird gespeichert …</p>
}
@if (view() === 'scenarios') {
<p class="scenario-help">
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus.
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen.
</p>
}
<ag-grid-angular
class="furniture-grid"
[theme]="gridTheme"
[rowData]="rows()"
[columnDefs]="columns()"
[defaultColDef]="defaultColDef"
[getRowId]="getRowId"
[rowSelection]="rowSelection"
[loading]="loading()"
[animateRows]="true"
[singleClickEdit]="true"
[stopEditingWhenCellsLoseFocus]="true"
(gridReady)="gridReady($event)"
(sortChanged)="sortChanged()"
(cellValueChanged)="cellChanged($event)"
(cellClicked)="cellClicked($event)"
(columnMoved)="saveState()"
(columnResized)="saveState()"
(columnVisible)="saveState()"
/>
<nav class="pager" aria-label="Grid-Seitennavigation">
<button type="button" [disabled]="page() <= 1" (click)="load(page() - 1)">Zurück</button>
<span>Seite {{ page() }} von {{ totalPages() }} · {{ totalItems() }} Einträge</span>
<button type="button" [disabled]="page() >= totalPages()" (click)="load(page() + 1)">
Weiter
</button>
</nav>
@if (view() === 'scenarios') {
<div class="scenario-summary">
@for (scenario of scenarios; track scenario.id) {
<article class="ui-card">
<strong>{{ scenario.name }}</strong
><span>{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</span
><small>{{ scenario.openRequirements }} offene Bedarfe</small>
</article>
}
</div>
}
</section>
`,
styles: [
`
:host {
display: block;
}
.grid-shell {
display: grid;
gap: var(--space-3);
}
.grid-tabs {
display: flex;
gap: var(--space-2);
overflow: auto;
}
.grid-tabs button {
min-height: 2.75rem;
border: 0;
border-bottom: 0.2rem solid transparent;
background: transparent;
color: var(--color-text-muted);
padding: var(--space-2) var(--space-4);
cursor: pointer;
}
.grid-tabs button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 700;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
}
.toolbar label {
display: grid;
gap: var(--space-1);
min-width: 11rem;
}
.toolbar label:has(input[type='checkbox']) {
display: flex;
min-width: auto;
align-items: center;
min-height: 2.75rem;
}
.toolbar input:not([type='checkbox']),
.toolbar select {
min-height: 2.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
padding: 0 var(--space-2);
}
.furniture-grid {
width: 100%;
height: min(62vh, 42rem);
min-height: 25rem;
}
.pager {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-3);
}
.pager button {
min-height: 2.75rem;
}
.row-error {
color: var(--color-danger);
border-left: 0.25rem solid var(--color-danger);
padding: var(--space-2);
}
.save-state {
color: var(--color-text-muted);
}
.scenario-help {
margin: 0;
color: var(--color-info);
background: var(--color-info-subtle);
border-left: 0.25rem solid var(--color-info);
padding: var(--space-3) var(--space-4);
}
.scenario-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--space-3);
}
.scenario-summary article {
display: grid;
gap: var(--space-1);
}
@media (max-width: 40rem) {
.toolbar {
display: grid;
}
.toolbar label {
min-width: 0;
}
.furniture-grid {
height: 32rem;
}
}
`,
],
})
export class FurnitureGridComponent implements OnChanges {
@Input({ required: true }) projectId = '';
@Input() rooms: Room[] = [];
@Input() canEdit = false;
@Input() scenarios: FurnitureScenario[] = [];
@Output() editRequirement = new EventEmitter<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>();
@Output() dataChanged = new EventEmitter<void>();
private readonly api = inject(HauspilotApiService);
private readonly auth = inject(AuthService);
private gridApi?: GridApi<FurnitureRow>;
private rollback = false;
readonly view = signal<View>('requirements');
readonly rows = signal<FurnitureRow[]>([]);
readonly loading = signal(false);
readonly savingRow = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly page = signal(1);
readonly totalPages = signal(1);
readonly totalItems = signal(0);
readonly searchChanges = new Subject<string>();
search = '';
roomId = '';
openOnly = false;
overBudget = false;
favoriteOnly = false;
delayedOnly = false;
readonly views: Array<{ id: View; label: string }> = [
{ id: 'requirements', label: 'Bedarfe' },
{ id: 'options', label: 'Alternativen' },
{ id: 'orders', label: 'Bestellungen' },
{ id: 'scenarios', label: 'Szenarien' },
];
readonly gridTheme = themeQuartz.withParams({
accentColor: 'var(--color-primary)',
backgroundColor: 'var(--color-surface)',
foregroundColor: 'var(--color-text)',
borderColor: 'var(--color-border)',
headerBackgroundColor: 'var(--color-surface-muted)',
rowHoverColor: 'var(--color-surface-muted)',
});
readonly defaultColDef: ColDef<FurnitureRow> = { sortable: true, resizable: true, minWidth: 110 };
readonly rowSelection = { mode: 'multiRow' as const, checkboxes: true, headerCheckbox: true };
readonly getRowId = (params: { data: FurnitureRow }) => params.data.id;
readonly columns = signal<ColDef<FurnitureRow>[]>([]);
constructor() {
this.searchChanges
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe(() => this.load(1));
}
ngOnChanges() {
this.refreshColumns();
if (this.projectId) this.load(1);
}
setView(view: View) {
this.view.set(view);
this.refreshColumns();
this.load(1);
setTimeout(() => this.restoreState());
}
gridReady(event: GridReadyEvent<FurnitureRow>) {
this.gridApi = event.api;
this.restoreState();
}
load(page = 1) {
if (!this.projectId) return;
this.loading.set(true);
this.error.set(null);
const sort = this.gridApi?.getColumnState().find((column) => column.sort);
const fallbackSort =
this.view() === 'requirements' || this.view() === 'scenarios' ? 'sortOrder' : 'updatedAt';
const sortBy = this.allowedSorts().has(sort?.colId ?? '')
? (sort?.colId ?? fallbackSort)
: fallbackSort;
const query = {
page,
pageSize: 50,
search: this.search,
roomId: this.roomId,
sortBy,
sortDirection: sort?.sort === 'asc' ? 'ASC' : 'DESC',
...(this.openOnly ? { openDecision: true } : {}),
...(this.overBudget ? { overBudget: true } : {}),
...(this.favoriteOnly ? { favorite: true } : {}),
...(this.delayedOnly ? { delayed: true } : {}),
...(this.view() === 'orders' ? { ordered: true } : {}),
};
const request: Observable<PageResult<FurnitureRow>> =
this.view() === 'requirements' || this.view() === 'scenarios'
? this.api.furnitureRequirements(this.projectId, query)
: this.api.furnitureProjectOptions(this.projectId, query);
request.subscribe({
next: (result) => {
this.rows.set(this.withScenarioSelections(result.items));
this.page.set(result.page);
this.totalPages.set(Math.max(1, result.totalPages));
this.totalItems.set(result.totalItems);
this.loading.set(false);
},
error: (e: unknown) => this.fail(e),
});
}
sortChanged() {
if (!this.loading()) this.load(1);
this.persistState();
}
cellChanged(event: CellValueChangedEvent<FurnitureRow>) {
if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return;
const scenarioId = this.scenarioId(event.column.getColId());
if (scenarioId && 'requiredQuantity' in event.data) {
this.saveScenarioSelection(event, scenarioId, event.data);
return;
}
const row = event.data;
this.savingRow.set(row.id);
this.error.set(null);
const request: Observable<FurnitureRow> =
'requiredQuantity' in row
? this.api.updateFurnitureRequirement(this.projectId, row, this.requirementBody(row))
: this.api.updateFurnitureOption(this.projectId, row, this.optionBody(row));
request.subscribe({
next: (saved) => {
event.node.setData(saved);
this.savingRow.set(null);
this.dataChanged.emit();
},
error: (error: unknown) => {
this.rollback = true;
event.node.setDataValue(event.column.getColId(), event.oldValue);
this.rollback = false;
this.savingRow.set(null);
this.fail(error);
if (this.status(error) === 409) this.load(this.page());
},
});
}
cellClicked(event: CellClickedEvent<FurnitureRow>) {
if (!event.data) return;
const id = event.column.getColId();
if ('requiredQuantity' in event.data) {
if (id === 'actions') this.editRequirement.emit(event.data);
if (id === 'addOption') this.addOption.emit(event.data);
return;
}
if (id === 'actions') this.editOption.emit(event.data);
if (!this.canEdit) return;
if (id === 'favorite')
this.api.favoriteFurnitureOption(this.projectId, event.data.id).subscribe({
next: () => {
this.load(this.page());
this.dataChanged.emit();
},
error: (e: unknown) => this.fail(e),
});
if (id === 'currentlySelected')
this.api.selectFurnitureOption(this.projectId, event.data).subscribe({
next: () => {
this.load(this.page());
this.dataChanged.emit();
},
error: (e: unknown) => {
this.fail(e);
this.load(this.page());
},
});
}
exportCsv() {
this.gridApi?.exportDataAsCsv({ fileName: `hauspilot-${this.view()}.csv` });
}
saveState() {
this.persistState();
}
private requirementColumns(): ColDef<FurnitureRow>[] {
return [
{
field: 'roomId',
headerName: 'Raum',
valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '',
editable: this.canEdit,
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: 'priority',
headerName: 'Priorität',
editable: this.canEdit,
cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(requirementPriorities) },
valueFormatter: (params) => this.present(params.value, requirementPriorities),
cellStyle: (params) => this.presentationStyle(params.value, requirementPriorities),
minWidth: 155,
},
{
field: 'requiredQuantity',
headerName: 'Menge',
editable: this.canEdit,
type: 'numericColumn',
},
{
field: 'status',
headerName: 'Status',
editable: this.canEdit,
cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(requirementStatuses) },
valueFormatter: (params) => this.present(params.value, requirementStatuses),
cellStyle: (params) => this.presentationStyle(params.value, requirementStatuses),
minWidth: 205,
},
{
field: 'maximumBudget',
headerName: 'Maximalbudget',
editable: this.canEdit,
valueFormatter: (p) => money(p.value),
valueParser: (p) => parseGermanNumber(p.newValue),
},
{ field: 'optionCount', headerName: 'Alternativen' },
{
field: 'cheapestOption',
headerName: 'Günstigste',
valueFormatter: (p) => this.optionLabel(p.value, 'price'),
minWidth: 190,
},
{
field: 'favoriteOption',
headerName: 'Favorit',
valueFormatter: (p) => this.optionLabel(p.value, 'favorite'),
},
{
field: 'selectedOption',
headerName: 'Ausgewählt',
valueFormatter: (p) => this.optionLabel(p.value, 'selected'),
},
{
field: 'selectedTotalPrice',
headerName: 'Gesamtpreis',
valueFormatter: (p) => (p.value ? money(p.value) : ''),
},
{
field: 'budgetVariance',
headerName: 'Abweichung',
valueFormatter: (p) =>
p.value === null ? '' : `${Number(p.value) > 0 ? '⚠ ' : '✓ '}${money(p.value)}`,
},
{
field: 'orderStatus',
headerName: 'Bestellung',
valueFormatter: (params) => this.present(params.value, deliveryStatuses),
cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses),
minWidth: 180,
},
{ field: 'expectedDelivery', headerName: 'Lieferung' },
{
field: 'updatedAt',
headerName: 'Geändert',
valueFormatter: (p) => this.formatDate(p.value),
},
{
colId: 'addOption',
headerName: 'Alternative',
valueGetter: () => (this.canEdit ? ' hinzufügen' : 'anzeigen'),
sortable: false,
},
{
colId: 'actions',
headerName: 'Aktionen',
valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'),
sortable: false,
pinned: 'right',
},
];
}
private refreshColumns() {
this.columns.set(
this.view() === 'requirements'
? this.requirementColumns()
: this.view() === 'scenarios'
? this.scenarioColumns()
: this.optionColumns(),
);
}
private allowedSorts() {
return new Set(
this.view() === 'requirements' || this.view() === 'scenarios'
? [
'name',
'room',
'category',
'priority',
'status',
'updatedAt',
'sortOrder',
'price',
'deliveryDate',
]
: [
'name',
'retailer',
'unitPrice',
'totalPrice',
'status',
'availability',
'expectedDeliveryDate',
'updatedAt',
'room',
'requirement',
],
);
}
private optionColumns(): ColDef<FurnitureRow>[] {
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: 'articleNumber', headerName: 'Artikelnummer' },
...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map(
(field) => ({
field,
headerName: {
unitPrice: 'Einzelpreis',
quantity: 'Menge',
shippingCost: 'Versand',
additionalCost: 'Zusatzkosten',
discount: 'Rabatt',
}[field],
editable: this.canEdit,
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: 'availability',
headerName: 'Verfügbarkeit',
editable: this.canEdit,
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: 'favorite',
headerName: 'Favorit',
valueFormatter: (p) => (p.value ? '★ Favorit' : '☆ setzen'),
sortable: false,
},
{
field: 'currentlySelected',
headerName: 'Auswahl',
valueFormatter: (p) => (p.value ? '◉ Ausgewählt' : '○ auswählen'),
sortable: false,
},
{
field: 'status',
headerName: 'Status',
editable: this.canEdit,
cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: Object.keys(optionStatuses) },
valueFormatter: (params) => this.present(params.value, optionStatuses),
cellStyle: (params) => this.presentationStyle(params.value, optionStatuses),
minWidth: 165,
},
{
field: 'deliveryStatus',
headerName: 'Lieferstatus',
valueFormatter: (params) => this.present(params.value, deliveryStatuses),
cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses),
minWidth: 185,
},
{ field: 'orderNumber', headerName: 'Bestellnummer' },
{
field: 'orderedAt',
headerName: 'Bestellt am',
valueFormatter: (p) => this.formatDate(p.value),
},
{ field: 'actualDeliveryDate', headerName: 'Geliefert am' },
{
colId: 'actions',
headerName: 'Aktionen',
valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'),
sortable: false,
pinned: 'right',
},
] as ColDef<FurnitureRow>[];
}
private scenarioColumns(): ColDef<FurnitureRow>[] {
const scenarioColumns: ColDef<FurnitureRow>[] = this.scenarios.map(
(scenario): ColDef<FurnitureRow> => ({
colId: `scenario:${scenario.id}`,
field: `scenarioSelections.${scenario.id}` as never,
headerName: `${scenario.name} · ${money(scenario.total)}`,
valueFormatter: (params) => {
const row = params.data;
if (!row || !('requiredQuantity' in row) || typeof params.value !== 'string') return '—';
const option = row.options.find((entry) => entry.id === params.value);
return option ? `${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen';
},
editable: (params) =>
this.canEdit &&
!!params.data &&
'requiredQuantity' in params.data &&
this.selectableOptions(params.data).length > 0,
cellEditor: 'agSelectCellEditor',
cellEditorParams: (params: { data?: FurnitureRow }) => ({
values:
params.data && 'requiredQuantity' in params.data
? ['', ...this.selectableOptions(params.data).map((option) => option.id)]
: [''],
}),
cellStyle: (params) =>
params.value
? {
color: 'var(--color-success)',
backgroundColor: 'var(--color-success-subtle)',
fontWeight: '650',
}
: {
color: 'var(--color-warning)',
backgroundColor: 'var(--color-warning-subtle)',
},
minWidth: 245,
}),
);
return [
{
field: 'roomId',
headerName: 'Raum',
valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '',
},
{ field: 'category', headerName: 'Kategorie' },
{ field: 'name', headerName: 'Möbelbedarf', minWidth: 180 },
...scenarioColumns,
];
}
private requirementBody(row: FurnitureRequirement) {
return {
roomId: row.roomId,
name: row.name,
description: row.description ?? '',
category: row.category,
priority: row.priority,
requiredQuantity: Number(row.requiredQuantity),
status: row.status,
...(row.responsibleUserId ? { responsibleUserId: row.responsibleUserId } : {}),
...(row.maximumBudget !== null ? { maximumBudget: Number(row.maximumBudget) } : {}),
sortOrder: row.sortOrder,
};
}
private withScenarioSelections(items: FurnitureRow[]): FurnitureRow[] {
return items.map((item) => {
if (!('requiredQuantity' in item)) return item;
return {
...item,
scenarioSelections: Object.fromEntries(
this.scenarios.map((scenario) => [
scenario.id,
scenario.selections.find((selection) => selection.requirementId === item.id)
?.optionId ?? '',
]),
),
};
});
}
private scenarioId(columnId: string) {
return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null;
}
private selectableOptions(requirement: FurnitureRequirement) {
return requirement.options.filter(
(option) =>
!['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) &&
!['unavailable', 'discontinued'].includes(option.availability),
);
}
private saveScenarioSelection(
event: CellValueChangedEvent<FurnitureRow>,
scenarioId: string,
requirement: FurnitureRequirement,
) {
const scenario = this.scenarios.find((entry) => entry.id === scenarioId);
if (!scenario) {
this.rollbackCell(event);
return;
}
const optionId = typeof event.newValue === 'string' ? event.newValue : '';
const option = optionId
? this.selectableOptions(requirement).find((entry) => entry.id === optionId)
: undefined;
if (optionId && !option) {
this.rollbackCell(event);
this.error.set('Diese Alternative kann dem Szenario nicht zugewiesen werden.');
return;
}
const selections = scenario.selections
.filter((selection) => selection.requirementId !== requirement.id)
.map((selection) => ({ ...selection }));
if (option)
selections.push({
requirementId: requirement.id,
optionId: option.id,
quantity: option.quantity,
});
this.savingRow.set(requirement.id);
this.error.set(null);
this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({
next: (saved) => {
this.scenarios = this.scenarios.map((entry) => (entry.id === saved.id ? saved : entry));
this.savingRow.set(null);
this.refreshColumns();
this.dataChanged.emit();
},
error: (error: unknown) => {
this.rollbackCell(event);
this.savingRow.set(null);
this.fail(error);
if (this.status(error) === 409) this.load(this.page());
},
});
}
private rollbackCell(event: CellValueChangedEvent<FurnitureRow>) {
this.rollback = true;
event.node.setDataValue(event.column.getColId(), event.oldValue);
this.rollback = false;
}
private optionBody(row: FurnitureOption) {
return {
name: row.name,
manufacturer: row.manufacturer ?? '',
model: row.model ?? '',
description: row.description ?? '',
retailer: row.retailer ?? '',
...(row.productUrl ? { productUrl: row.productUrl } : {}),
articleNumber: row.articleNumber ?? '',
unitPrice: Number(row.unitPrice),
shippingCost: Number(row.shippingCost),
additionalCost: Number(row.additionalCost),
discount: Number(row.discount),
currency: row.currency,
quantity: Number(row.quantity),
...(row.width !== null ? { width: Number(row.width) } : {}),
...(row.height !== null ? { height: Number(row.height) } : {}),
...(row.depth !== null ? { depth: Number(row.depth) } : {}),
color: row.color ?? '',
material: row.material ?? '',
...(row.deliveryDays !== null ? { deliveryDays: row.deliveryDays } : {}),
...(row.expectedDeliveryDate ? { expectedDeliveryDate: row.expectedDeliveryDate } : {}),
availability: row.availability,
favorite: row.favorite,
status: row.status,
notes: row.notes ?? '',
existingItem: row.existingItem,
movingCost: Number(row.movingCost),
refurbishmentCost: Number(row.refurbishmentCost),
};
}
private stateKey() {
return `hauspilot:grid:furniture:${this.auth.user()?.id ?? 'anonymous'}:${this.projectId}:${this.view()}`;
}
private optionLabel(value: unknown, kind: 'price' | 'favorite' | 'selected') {
if (!this.isOption(value))
return kind === 'price' ? '⚠ Preis offen' : kind === 'selected' ? '⚠ offen' : '';
if (kind === 'price') return `${value.name} · ${money(value.totalPrice)}`;
return `${kind === 'favorite' ? '★' : '✓'} ${value.name}`;
}
private present(value: unknown, presentations: Record<string, GridValuePresentation>) {
if (typeof value !== 'string' || !value) return '—';
const presentation = presentations[value];
return presentation ? `${presentation.icon} ${presentation.label}` : value;
}
private presentationStyle(
value: unknown,
presentations: Record<string, GridValuePresentation>,
): CellStyle {
const tone = typeof value === 'string' ? presentations[value]?.tone : undefined;
const colors: Record<GridValuePresentation['tone'], CellStyle> = {
neutral: {
color: 'var(--color-text-muted)',
backgroundColor: 'var(--color-neutral-subtle)',
},
info: { color: 'var(--color-info)', backgroundColor: 'var(--color-info-subtle)' },
success: {
color: 'var(--color-success)',
backgroundColor: 'var(--color-success-subtle)',
},
warning: {
color: 'var(--color-warning)',
backgroundColor: 'var(--color-warning-subtle)',
},
danger: { color: 'var(--color-danger)', backgroundColor: 'var(--color-danger-subtle)' },
};
return tone ? { ...colors[tone], fontWeight: '650' } : { color: 'var(--color-text-muted)' };
}
private isOption(value: unknown): value is FurnitureOption {
return typeof value === 'object' && value !== null && 'name' in value && 'totalPrice' in value;
}
private formatDate(value: unknown) {
return typeof value === 'string' || typeof value === 'number' || value instanceof Date
? new Date(value).toLocaleDateString('de-DE')
: '';
}
private persistState() {
if (this.gridApi)
localStorage.setItem(this.stateKey(), JSON.stringify(this.gridApi.getColumnState()));
}
private restoreState() {
const saved = localStorage.getItem(this.stateKey());
if (saved && this.gridApi) {
try {
this.gridApi.applyColumnState({ state: JSON.parse(saved) as never[], applyOrder: true });
} catch {
localStorage.removeItem(this.stateKey());
}
}
}
private status(error: unknown) {
return typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number'
? error.status
: 0;
}
private fail(error: unknown) {
this.loading.set(false);
const fallback =
typeof error === 'object' &&
error !== null &&
'error' in error &&
typeof error.error === 'object' &&
error.error !== null &&
'message' in error.error &&
typeof error.error.message === 'string'
? error.error.message
: 'Die Möbelansicht konnte nicht aktualisiert werden.';
this.error.set(conflictMessage(this.status(error), fallback));
}
}

View File

@@ -0,0 +1,80 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { FurniturePlanningComponent } from './furniture-planning.component';
describe('FurniturePlanningComponent', () => {
it('calculates option totals including quantity, shipping, extras and discounts', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient()],
}).compileComponents();
const fixture = TestBed.createComponent(FurniturePlanningComponent);
const component = fixture.componentInstance;
component.optionForm.patchValue({
unitPrice: 100,
quantity: 2,
shippingCost: 25,
additionalCost: 10,
discount: 5,
movingCost: 0,
refurbishmentCost: 0,
});
expect(component.calculatedTotal()).toBe(230);
});
it('does not include acquisition price for existing furniture', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient()],
}).compileComponents();
const component = TestBed.createComponent(FurniturePlanningComponent).componentInstance;
component.optionForm.patchValue({
existingItem: true,
unitPrice: 900,
quantity: 1,
shippingCost: 0,
additionalCost: 0,
discount: 0,
movingCost: 80,
refurbishmentCost: 35,
});
expect(component.calculatedTotal()).toBe(115);
});
it('opens furniture details in a modal dialog', async () => {
await TestBed.configureTestingModule({
imports: [FurniturePlanningComponent],
providers: [provideHttpClient()],
}).compileComponents();
const fixture = TestBed.createComponent(FurniturePlanningComponent);
fixture.detectChanges();
const root: unknown = fixture.nativeElement;
if (!(root instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.');
const dialog = root.querySelector('dialog');
if (!(dialog instanceof HTMLDialogElement)) throw new Error('Möbeldialog fehlt.');
const showModal = vi.fn();
Object.defineProperty(dialog, 'showModal', { value: showModal });
fixture.componentInstance.editRequirement({
id: 'requirement-1',
version: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
projectId: 'project-1',
roomId: 'room-1',
name: 'Sofa',
description: null,
category: 'seating',
priority: 'normal',
requiredQuantity: 1,
status: 'identified',
responsibleUserId: null,
maximumBudget: null,
sortOrder: 0,
options: [],
});
await Promise.resolve();
expect(showModal).toHaveBeenCalledOnce();
expect(fixture.componentInstance.showRequirementForm()).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HauspilotApiService } from './hauspilot-api.service';
describe('HauspilotApiService', () => {
it('überträgt Pagination, Whitelist-Sortierung und Aufgabenfilter an das Backend', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const service = TestBed.inject(HauspilotApiService);
const http = TestBed.inject(HttpTestingController);
service
.tasks('project-1', {
page: 2,
pageSize: 25,
sortBy: 'dueDate',
statuses: ['planned', 'blocked'],
mine: true,
})
.subscribe((result) => expect(result.totalItems).toBe(1));
const request = http.expectOne(
(candidate) => candidate.url === '/api/projects/project-1/tasks',
);
expect(request.request.params.get('page')).toBe('2');
expect(request.request.params.get('statuses')).toBe('planned,blocked');
expect(request.request.params.get('mine')).toBe('true');
request.flush({ items: [], page: 2, pageSize: 25, totalItems: 1, totalPages: 1 });
http.verify();
});
it('lädt Kalenderdaten ausschließlich für den angeforderten Zeitraum', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const service = TestBed.inject(HauspilotApiService);
const http = TestBed.inject(HttpTestingController);
service.calendar('project-1', '2026-07-01', '2026-07-31', { roomId: 'room-1' }).subscribe();
const request = http.expectOne((candidate) => candidate.url.endsWith('/calendar'));
expect(request.request.params.get('from')).toBe('2026-07-01');
expect(request.request.params.get('to')).toBe('2026-07-31');
expect(request.request.params.get('roomId')).toBe('room-1');
request.flush([]);
http.verify();
});
});

View File

@@ -0,0 +1,568 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
export interface Versioned {
id: string;
version: number;
createdAt: string;
updatedAt: string;
}
export interface PageResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
export interface Building extends Versioned {
projectId: string;
name: string;
description: string | null;
type: string;
sortOrder: number;
}
export interface Floor extends Versioned {
projectId: string;
buildingId: string;
name: string;
description: string | null;
sortOrder: number;
}
export interface Room extends Versioned {
projectId: string;
floorId: string;
name: string;
type: string;
status: string;
area: string | null;
plannedBudget: string | null;
sortOrder: number;
description: string | null;
previewDocumentId: string | null;
}
export interface RenovationTask extends Versioned {
projectId: string;
roomId: string | null;
title: string;
category: string;
status: string;
priority: string;
assigneeUserId: string | null;
dueDate: string | null;
estimatedCost: string | null;
actualCost: string | null;
blockedByDependencies: boolean;
description: string | null;
plannedStartDate: string | null;
actualCompletionDate: string | null;
estimatedEffortHours: string | null;
blockingReason: string | null;
weight: string;
}
export interface ChecklistItem {
id: string;
text: string;
completed: boolean;
sortOrder: number;
completedByUserId: string | null;
completedAt: string | null;
}
export interface TaskDependency {
id: string;
predecessorTaskId: string;
successorTaskId: string;
predecessor?: RenovationTask;
successor?: RenovationTask;
}
export interface TaskComment {
id: string;
text: string;
authorUserId: string;
authorName?: string;
mentionedUserIds?: string[];
createdAt: string;
updatedAt: string;
}
export interface TaskDetail extends RenovationTask {
checklist: ChecklistItem[];
dependencies: TaskDependency[];
comments: TaskComment[];
documents: ProjectDocument[];
activities: ProjectActivity[];
assigneeName: string | null;
}
export type TaskPatch = Partial<
Omit<RenovationTask, 'estimatedEffortHours' | 'estimatedCost' | 'actualCost' | 'weight'>
> & {
estimatedEffortHours?: number | string | null;
estimatedCost?: number | string | null;
actualCost?: number | string | null;
weight?: number | string;
};
export interface Milestone extends Versioned {
projectId: string;
title: string;
description: string | null;
date: string;
status: string;
type: string;
responsibleUserId: string | null;
}
export interface BudgetCategory extends Versioned {
projectId: string;
name: string;
plannedBudget: string;
sortOrder: number;
active: boolean;
}
export interface Expense extends Versioned {
projectId: string;
budgetCategoryId: string;
roomId: string | null;
taskId: string | null;
title: string;
description: string | null;
amount: string;
currency: string;
paymentStatus: string;
expenseDate: string;
dueDate: string | null;
supplier: string | null;
invoiceNumber: string | null;
documentId: string | null;
furnitureRequirementId?: string | null;
furnitureOptionId?: string | null;
}
export interface ProjectDocument extends Versioned {
title: string;
type: string;
originalFilename: string;
mimeType: string;
fileSize: number;
uploadedAt: string;
description: string | null;
roomId: string | null;
taskId: string | null;
}
export interface ProjectDashboard {
progress: number;
tasks: {
open: number;
overdue: number;
blocked: number;
critical: number;
unassigned: number;
mine: number;
};
rooms: { total: number; done: number; renovating: number };
budget: { planned: number; actual: number; paid: number; open: number; remaining: number };
milestones: (Milestone & { atRisk: boolean })[];
activeMembers: number;
furniture: FurnitureSummary;
hints: string[];
}
export interface ProjectActivity {
id: string;
action: string;
actorName: string;
metadata: Record<string, string | number | boolean | null> | null;
createdAt: string;
}
export interface CalendarEvent {
id: string;
entityId: string;
type: 'task_start' | 'task_due' | 'milestone' | 'expense_due';
title: string;
date: string;
completed: boolean;
overdue: boolean;
roomId?: string | null;
assigneeUserId?: string | null;
}
export interface ProjectTemplate {
id: string;
name: string;
description: string;
requiresRoom: boolean;
}
export interface FurnitureOption extends Versioned {
projectId: string;
requirementId: string;
name: string;
manufacturer: string | null;
model: string | null;
description: string | null;
retailer: string | null;
productUrl: string | null;
articleNumber: string | null;
unitPrice: string;
originalPrice: string | null;
shippingCost: string;
additionalCost: string;
discount: string;
totalPrice: string;
currency: string;
quantity: number;
width: string | null;
height: string | null;
depth: string | null;
weight: string | null;
color: string | null;
material: string | null;
deliveryDays: number | null;
expectedDeliveryDate: string | null;
availability: string;
favorite: boolean;
currentlySelected: boolean;
status: string;
notes: string | null;
budgetCategoryId: string | null;
existingItem: boolean;
movingCost: string;
refurbishmentCost: string;
deliveryStatus: string;
deliveredQuantity: number;
orderNumber: string | null;
orderedAt: string | null;
actualDeliveryDate?: string | null;
requirementName?: string;
roomId?: string;
roomName?: string;
}
export interface FurnitureRequirement extends Versioned {
projectId: string;
roomId: string;
name: string;
description: string | null;
category: string;
priority: string;
requiredQuantity: number;
status: string;
responsibleUserId: string | null;
maximumBudget: string | null;
sortOrder: number;
options: FurnitureOption[];
optionCount?: number;
cheapestOption?: FurnitureOption | null;
favoriteOption?: FurnitureOption | null;
selectedOption?: FurnitureOption | null;
selectedTotalPrice?: string | null;
budgetVariance?: string | null;
orderStatus?: string | null;
expectedDelivery?: string | null;
hasOpenDecision?: boolean;
isOverBudget?: boolean;
}
export interface FurnitureScenario extends Versioned {
projectId: string;
name: string;
description: string | null;
type: string;
status: string;
isDefault: boolean;
total: string;
selectedRequirements: number;
openRequirements: number;
byRoom: Record<string, string>;
selections: Array<{ requirementId: string; optionId: string; quantity: number }>;
}
export interface FurnitureSummary {
requirements: number;
withoutOption: number;
withoutDecision: number;
selected: number;
ordered: number;
delivered: number;
delayed: number;
budget: string;
cheapestCost: string;
favoriteCost: string;
selectedCost: string;
actualExpenseCost: string;
openPrices: number;
overBudget: number;
scenarios: Array<{ id: string; name: string; total: string }>;
}
export type ListQuery = Record<string, string | number | boolean | readonly string[]>;
@Injectable({ providedIn: 'root' })
export class HauspilotApiService {
private readonly http = inject(HttpClient);
private readonly base = '/api/projects';
dashboard(id: string) {
return this.http.get<ProjectDashboard>(`${this.base}/${id}/dashboard`);
}
buildings(id: string) {
return this.http.get<Building[]>(`${this.base}/${id}/buildings`);
}
floors(id: string) {
return this.http.get<Floor[]>(`${this.base}/${id}/floors`);
}
ensureDefaultFloors(id: string) {
return this.http.post<{ created: Floor[]; floors: Floor[] }>(
`${this.base}/${id}/floors/defaults`,
{},
);
}
private params(query: ListQuery = {}) {
let params = new HttpParams();
for (const [key, value] of Object.entries(query)) {
if (value === '' || value === undefined) continue;
params = params.set(key, Array.isArray(value) ? value.join(',') : String(value));
}
return params;
}
rooms(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<Room>>(`${this.base}/${id}/rooms`, {
params: this.params(query),
});
}
tasks(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<RenovationTask>>(`${this.base}/${id}/tasks`, {
params: this.params(query),
});
}
task(id: string, taskId: string) {
return this.http.get<TaskDetail>(`${this.base}/${id}/tasks/${taskId}`);
}
milestones(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<Milestone>>(`${this.base}/${id}/milestones`, {
params: this.params(query),
});
}
budgets(id: string) {
return this.http.get<BudgetCategory[]>(`${this.base}/${id}/budget-categories`);
}
expenses(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<Expense>>(`${this.base}/${id}/expenses`, {
params: this.params(query),
});
}
documents(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<ProjectDocument>>(`${this.base}/${id}/documents`, {
params: this.params(query),
});
}
activities(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<ProjectActivity>>(`${this.base}/${id}/activities`, {
params: this.params(query),
});
}
calendar(id: string, from: string, to: string, query: ListQuery = {}) {
return this.http.get<CalendarEvent[]>(`${this.base}/${id}/calendar`, {
params: this.params({ ...query, from, to }),
});
}
templates() {
return this.http.get<ProjectTemplate[]>('/api/templates');
}
applyTemplate(
id: string,
templateId: string,
body: { roomId?: string; confirmDuplicate?: boolean } = {},
) {
return this.http.post<{ applied: true }>(
`${this.base}/${id}/apply-template/${templateId}`,
body,
);
}
createBuilding(id: string, body: { name: string; type: string }) {
return this.http.post<Building>(`${this.base}/${id}/buildings`, body);
}
updateBuilding(id: string, building: Building, body: object) {
return this.http.patch<Building>(`${this.base}/${id}/buildings/${building.id}`, {
...body,
version: building.version,
});
}
createFloor(id: string, body: { buildingId: string; name: string }) {
return this.http.post<Floor>(`${this.base}/${id}/floors`, body);
}
updateFloor(id: string, floor: Floor, body: object) {
return this.http.patch<Floor>(`${this.base}/${id}/floors/${floor.id}`, {
...body,
version: floor.version,
});
}
createRoom(id: string, body: { floorId: string; name: string; type: string; status: string }) {
return this.http.post<Room>(`${this.base}/${id}/rooms`, body);
}
updateRoom(id: string, room: Room, body: object) {
return this.http.patch<Room>(`${this.base}/${id}/rooms/${room.id}`, {
...body,
version: room.version,
});
}
createTask(
id: string,
body: TaskPatch & { title: string; category: string; status: string; priority: string },
) {
return this.http.post<RenovationTask>(`${this.base}/${id}/tasks`, body);
}
updateTask(id: string, task: RenovationTask, patch: TaskPatch) {
return this.http.patch<RenovationTask>(`${this.base}/${id}/tasks/${task.id}`, {
...task,
...patch,
version: task.version,
});
}
addChecklist(id: string, taskId: string, text: string) {
return this.http.post<ChecklistItem>(`${this.base}/${id}/tasks/${taskId}/checklist`, { text });
}
updateChecklist(id: string, taskId: string, itemId: string, body: Partial<ChecklistItem>) {
return this.http.patch<ChecklistItem>(
`${this.base}/${id}/tasks/${taskId}/checklist/${itemId}`,
body,
);
}
addDependency(id: string, taskId: string, predecessorTaskId: string) {
return this.http.post<TaskDependency>(`${this.base}/${id}/tasks/${taskId}/dependencies`, {
predecessorTaskId,
});
}
removeDependency(id: string, taskId: string, dependencyId: string) {
return this.http.delete<void>(
`${this.base}/${id}/tasks/${taskId}/dependencies/${dependencyId}`,
);
}
addComment(id: string, taskId: string, text: string, mentionedUserIds: string[]) {
return this.http.post<TaskComment>(`${this.base}/${id}/tasks/${taskId}/comments`, {
text,
mentionedUserIds,
});
}
createBudget(id: string, body: { name: string; plannedBudget: number }) {
return this.http.post<BudgetCategory>(`${this.base}/${id}/budget-categories`, body);
}
updateBudget(id: string, category: BudgetCategory, body: object) {
return this.http.patch<BudgetCategory>(`${this.base}/${id}/budget-categories/${category.id}`, {
...body,
version: category.version,
});
}
createMilestone(id: string, body: object) {
return this.http.post<Milestone>(`${this.base}/${id}/milestones`, body);
}
updateMilestone(id: string, milestone: Milestone, body: object) {
return this.http.patch<Milestone>(`${this.base}/${id}/milestones/${milestone.id}`, {
...body,
version: milestone.version,
});
}
createExpense(id: string, body: object) {
return this.http.post<Expense>(`${this.base}/${id}/expenses`, body);
}
updateExpense(id: string, expense: Expense, body: object) {
return this.http.patch<Expense>(`${this.base}/${id}/expenses/${expense.id}`, {
...body,
version: expense.version,
});
}
upload(id: string, data: FormData) {
return this.http.post<ProjectDocument>(`${this.base}/${id}/documents`, data);
}
updateDocument(id: string, document: ProjectDocument, body: object) {
return this.http.patch<ProjectDocument>(`${this.base}/${id}/documents/${document.id}`, {
...body,
version: document.version,
});
}
downloadUrl(id: string, documentId: string) {
return `${this.base}/${id}/documents/${documentId}/download`;
}
furnitureRequirements(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<FurnitureRequirement>>(
`${this.base}/${id}/furniture-requirements`,
{ params: this.params(query) },
);
}
furnitureRequirement(id: string, requirementId: string) {
return this.http.get<FurnitureRequirement>(
`${this.base}/${id}/furniture-requirements/${requirementId}`,
);
}
createFurnitureRequirement(id: string, body: object) {
return this.http.post<FurnitureRequirement>(`${this.base}/${id}/furniture-requirements`, body);
}
updateFurnitureRequirement(id: string, requirement: FurnitureRequirement, body: object) {
return this.http.patch<FurnitureRequirement>(
`${this.base}/${id}/furniture-requirements/${requirement.id}`,
{ ...body, version: requirement.version },
);
}
furnitureOptions(id: string, requirementId: string) {
return this.http.get<FurnitureOption[]>(
`${this.base}/${id}/furniture-requirements/${requirementId}/options`,
);
}
furnitureProjectOptions(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<FurnitureOption>>(`${this.base}/${id}/furniture-options`, {
params: this.params(query),
});
}
createFurnitureOption(id: string, requirementId: string, body: object) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-requirements/${requirementId}/options`,
body,
);
}
updateFurnitureOption(id: string, option: FurnitureOption, body: object) {
return this.http.patch<FurnitureOption>(`${this.base}/${id}/furniture-options/${option.id}`, {
...body,
version: option.version,
});
}
favoriteFurnitureOption(id: string, optionId: string) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${optionId}/favorite`,
{},
);
}
selectFurnitureOption(id: string, option: FurnitureOption) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${option.id}/select`,
{ version: option.version },
);
}
orderFurnitureOption(id: string, option: FurnitureOption, body: object) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${option.id}/order`,
{ ...body, version: option.version },
);
}
deliverFurnitureOption(id: string, option: FurnitureOption, deliveredQuantity: number) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${option.id}/deliver`,
{ version: option.version, deliveredQuantity },
);
}
furnitureSummary(id: string, roomId?: string) {
const url = roomId
? `${this.base}/${id}/rooms/${roomId}/furniture-summary`
: `${this.base}/${id}/furniture-summary`;
return this.http.get<FurnitureSummary>(url);
}
furnitureScenarios(id: string) {
return this.http.get<FurnitureScenario[]>(`${this.base}/${id}/furniture-scenarios`);
}
createFurnitureScenario(id: string, body: object) {
return this.http.post<FurnitureScenario>(`${this.base}/${id}/furniture-scenarios`, body);
}
updateFurnitureScenarioSelections(
id: string,
scenario: FurnitureScenario,
selections: Array<{ requirementId: string; optionId: string; quantity: number }>,
) {
return this.http.put<FurnitureScenario>(
`${this.base}/${id}/furniture-scenarios/${scenario.id}/selections`,
{ version: scenario.version, selections },
);
}
compareFurnitureScenarios(id: string, ids: readonly string[]) {
return this.http.get<{
scenarios: FurnitureScenario[];
rooms: Array<{ id: string; name: string; costs: Record<string, string> }>;
}>(`${this.base}/${id}/furniture-scenarios/compare`, { params: this.params({ ids }) });
}
}

View File

@@ -0,0 +1,225 @@
import { Component, inject, signal } from '@angular/core';
import type { OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { ApiErrorBody, ProjectInvitationDto, ProjectRole } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
import { UiEmptyStateComponent, UiPageHeaderComponent } from '../../shared/ui';
@Component({
standalone: true,
imports: [UiEmptyStateComponent, UiPageHeaderComponent],
template: `
<ui-page-header
title="Projekteinladungen"
description="Einladungen werden nie automatisch angenommen."
/>
@if (loading()) {
<p>Einladung wird geprueft ...</p>
} @else if (error()) {
<section class="ui-card status-card" role="alert">
<h2>Einladung nicht verfuegbar</h2>
<p>{{ error() }}</p>
<p>Bitten Sie den Projekteigentuemer gegebenenfalls um eine neue Einladung.</p>
</section>
} @else if (tokenInvitation(); as invitation) {
<section class="ui-card invitation-card">
@if (invitation.reason === 'email_mismatch') {
<h2>Andere E-Mail-Adresse erforderlich</h2>
<p>
Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit der
eingeladenen Adresse an oder bitten Sie den Projekteigentuemer um eine neue Einladung.
</p>
<p>Eingeladene Adresse: {{ invitation.invitedEmailMasked }}</p>
<a class="ui-button ui-button--ghost" href="/api/auth/logout"
>Abmelden und Benutzer wechseln</a
>
} @else if (invitation.reason === 'email_unverified') {
<h2>E-Mail-Adresse noch nicht verifiziert</h2>
<p>
Verifizieren Sie die Adresse beim bestehenden Identity Provider und melden Sie sich
erneut an.
</p>
<a class="ui-button ui-button--ghost" href="/api/auth/logout">Erneut anmelden</a>
} @else if (invitation.status !== 'pending') {
<h2>Einladung {{ statusLabel(invitation.status) }}</h2>
<p>Diese Einladung kann nicht mehr verwendet werden.</p>
} @else {
<h2>{{ invitation.projectName }}</h2>
<p>
Vorgesehene Rolle: <strong>{{ roleLabel(invitation.role) }}</strong>
</p>
<p>{{ roleDescription(invitation.role) }}</p>
<div class="actions">
<button class="ui-button ui-button--primary" type="button" (click)="acceptToken()">
Einladung annehmen
</button>
<button class="ui-button ui-button--ghost" type="button" (click)="declineToken()">
Ablehnen
</button>
</div>
}
</section>
} @else if (pending().length === 0) {
<ui-empty-state
title="Keine offenen Einladungen"
message="Derzeit wartet keine Projekteinladung auf Ihre Entscheidung."
/>
} @else {
<section class="invitation-list" aria-label="Offene Einladungen">
@for (invitation of pending(); track invitation.id) {
<article class="ui-card invitation-card">
<h2>{{ invitation.projectName }}</h2>
<p>
Rolle: <strong>{{ roleLabel(invitation.role) }}</strong>
</p>
@if (invitation.canRespond) {
<div class="actions">
<button
class="ui-button ui-button--primary"
type="button"
(click)="acceptPending(invitation)"
>
Annehmen
</button>
<button
class="ui-button ui-button--ghost"
type="button"
(click)="declinePending(invitation)"
>
Ablehnen
</button>
</div>
} @else {
<p role="alert">Die E-Mail-Adresse muss beim Identity Provider verifiziert sein.</p>
}
</article>
}
</section>
}
`,
styles: [
`
:host,
.invitation-list,
.invitation-card,
.status-card {
display: grid;
gap: var(--space-4);
}
.invitation-card,
.status-card {
max-width: 44rem;
}
.invitation-card h2,
.invitation-card p,
.status-card h2,
.status-card p {
margin: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
`,
],
})
export class InvitationsPageComponent implements OnInit {
private readonly api = inject(ApiClientService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private token: string | null = null;
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly tokenInvitation = signal<ProjectInvitationDto | null>(null);
readonly pending = signal<ProjectInvitationDto[]>([]);
ngOnInit(): void {
this.token = this.route.snapshot.paramMap.get('token');
if (this.token) {
this.loadToken(this.token);
} else {
this.loadPending();
}
}
acceptToken(): void {
if (!this.token) return;
this.api.acceptProjectInvitation(this.token).subscribe({
next: (project) => void this.router.navigate(['/projekte', project.id]),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
});
}
declineToken(): void {
if (!this.token) return;
this.api.declineProjectInvitation(this.token).subscribe({
next: () => void this.router.navigate(['/einladungen']),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
});
}
acceptPending(invitation: ProjectInvitationDto): void {
this.api.acceptPendingProjectInvitation(invitation.id).subscribe({
next: (project) => void this.router.navigate(['/projekte', project.id]),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
});
}
declinePending(invitation: ProjectInvitationDto): void {
this.api.declinePendingProjectInvitation(invitation.id).subscribe({
next: () => this.pending.update((items) => items.filter((item) => item.id !== invitation.id)),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
});
}
roleLabel(role: ProjectRole): string {
return {
owner: 'Eigentuemer',
administrator: 'Projektadministrator',
editor: 'Bearbeiter',
reader: 'Leser',
}[role];
}
roleDescription(role: ProjectRole): string {
return {
owner: 'Eigentuemer verwalten das Projekt und seine Mitglieder.',
administrator: 'Projektadministratoren verwalten Inhalte und Mitglieder.',
editor: 'Bearbeiter koennen Projektinhalte lesen und aendern.',
reader: 'Leser koennen Projektinhalte ansehen.',
}[role];
}
statusLabel(status: ProjectInvitationDto['status']): string {
return {
pending: 'offen',
accepted: 'angenommen',
declined: 'abgelehnt',
revoked: 'widerrufen',
expired: 'abgelaufen',
}[status];
}
private loadToken(token: string): void {
this.api.projectInvitation(token).subscribe({
next: (invitation) => this.tokenInvitation.set(invitation),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
complete: () => this.loading.set(false),
});
}
private loadPending(): void {
this.api.pendingProjectInvitations().subscribe({
next: (invitations) => this.pending.set(invitations),
error: (error: { error?: ApiErrorBody }) => this.showError(error),
complete: () => this.loading.set(false),
});
}
private showError(error: { error?: ApiErrorBody }): void {
this.error.set(error.error?.message ?? 'Die Einladung ist ungueltig oder abgelaufen.');
this.loading.set(false);
}
}

View File

@@ -0,0 +1,342 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject, input, signal } from '@angular/core';
import type { OnInit } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import type { ProjectMemberDto } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
import { HauspilotApiService } from './hauspilot-api.service';
import type { CalendarEvent, Room } from './hauspilot-api.service';
@Component({
selector: 'app-project-calendar',
standalone: true,
imports: [DatePipe, ReactiveFormsModule, RouterLink],
template: `
<div class="calendar-toolbar">
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
(click)="move(-1)"
aria-label="Vorheriger Monat"
>
</button
><button class="ui-button ui-button--secondary" type="button" (click)="today()">
Heute</button
><button
class="ui-button ui-button--secondary"
type="button"
(click)="move(1)"
aria-label="Nächster Monat"
>
</button>
</div>
<h2>{{ cursor() | date: 'MMMM yyyy' }}</h2>
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
[attr.aria-pressed]="view() === 'month'"
(click)="view.set('month')"
>
Monat</button
><button
class="ui-button ui-button--secondary"
type="button"
[attr.aria-pressed]="view() === 'agenda'"
(click)="view.set('agenda')"
>
Agenda
</button>
</div>
</div>
<div class="filters">
<label
>Raum<select class="ui-control" [formControl]="roomFilter">
<option value="">Alle</option>
@for (room of rooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
<label
>Verantwortlich<select class="ui-control" [formControl]="assigneeFilter">
<option value="">Alle</option>
@for (member of members(); track member.userId) {
<option [value]="member.userId">{{ member.name }}</option>
}
</select></label
>
<label
>Ereignis<select class="ui-control" [formControl]="typeFilter">
<option value="">Alle</option>
<option value="task_start">Aufgabenstart</option>
<option value="task_due">Aufgabenfälligkeit</option>
<option value="milestone">Meilenstein</option>
<option value="expense_due">Ausgabe fällig</option>
</select></label
>
</div>
@if (loading()) {
<p role="status">Kalender wird geladen …</p>
}
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
@if (view() === 'month') {
<div class="weekdays" aria-hidden="true">
@for (day of weekdays; track day) {
<strong>{{ day }}</strong>
}
</div>
<div class="month-grid">
@for (day of days(); track day.key) {
<section
class="day"
[class.outside]="!day.currentMonth"
[class.today]="day.today"
[attr.aria-label]="day.date | date: 'fullDate'"
>
<time>{{ day.date | date: 'd' }}</time>
@for (event of eventsFor(day.key); track event.id) {
@if (event.type === 'task_start' || event.type === 'task_due') {
<a
class="event"
[class.overdue]="event.overdue"
[class.completed]="event.completed"
[routerLink]="['/projekte', projectId(), 'aufgaben', event.entityId]"
><span aria-hidden="true">{{ icon(event.type) }}</span
><span>{{ typeLabel(event.type) }}: {{ event.title }}</span></a
>
} @else {
<span
class="event"
[class.overdue]="event.overdue"
[class.completed]="event.completed"
><span aria-hidden="true">{{ icon(event.type) }}</span
><span>{{ typeLabel(event.type) }}: {{ event.title }}</span></span
>
}
}
</section>
}
</div>
} @else {
<div class="agenda">
@for (event of filteredEvents(); track event.id) {
<article class="ui-card agenda-row">
<time>{{ event.date | date: 'EEE, dd.MM.yyyy' }}</time
><span aria-hidden="true">{{ icon(event.type) }}</span>
@if (event.type === 'task_start' || event.type === 'task_due') {
<a [routerLink]="['/projekte', projectId(), 'aufgaben', event.entityId]">{{
event.title
}}</a>
} @else {
<strong>{{ event.title }}</strong>
}
<span>{{ typeLabel(event.type) }}</span>
@if (event.overdue) {
<span class="flag">Überfällig</span>
}
</article>
} @empty {
<p>In diesem Zeitraum gibt es keine passenden Termine.</p>
}
</div>
}
`,
styles: [
`
:host {
display: grid;
gap: var(--space-4);
}
.calendar-toolbar,
.actions,
.filters,
.agenda-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.calendar-toolbar {
justify-content: space-between;
}
.calendar-toolbar h2 {
margin: 0;
text-transform: capitalize;
}
.filters label {
display: grid;
gap: var(--space-1);
min-inline-size: 11rem;
}
.weekdays,
.month-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.weekdays {
text-align: center;
color: var(--color-text-secondary);
}
.day {
min-block-size: 8rem;
border: 1px solid var(--color-border);
padding: var(--space-2);
background: var(--color-surface);
overflow: hidden;
}
.day.outside {
opacity: 0.55;
}
.day.today {
outline: 0.2rem solid var(--color-primary);
outline-offset: -0.2rem;
}
.day time {
display: block;
font-weight: 700;
margin-block-end: var(--space-2);
}
.event {
display: flex;
gap: var(--space-1);
padding: var(--space-1);
margin-block: var(--space-1);
border-radius: var(--radius-sm);
background: var(--color-primary-subtle);
color: var(--color-text);
font-size: var(--font-size-xs);
text-decoration: none;
overflow-wrap: anywhere;
}
.event.overdue,
.flag {
color: var(--color-danger);
font-weight: 700;
}
.event.completed {
text-decoration: line-through;
}
.agenda {
display: grid;
gap: var(--space-2);
}
.agenda-row time {
min-inline-size: 9rem;
}
.error {
color: var(--color-danger);
}
@media (max-width: 48rem) {
.weekdays {
display: none;
}
.month-grid {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.day.outside {
display: none;
}
.day {
min-block-size: auto;
}
}
`,
],
})
export class ProjectCalendarComponent implements OnInit {
readonly projectId = input.required<string>();
private readonly api = inject(HauspilotApiService);
private readonly projectsApi = inject(ApiClientService);
readonly cursor = signal(new Date());
readonly view = signal<'month' | 'agenda'>('month');
readonly events = signal<CalendarEvent[]>([]);
readonly rooms = signal<Room[]>([]);
readonly members = signal<ProjectMemberDto[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly roomFilter = new FormControl('', { nonNullable: true });
readonly assigneeFilter = new FormControl('', { nonNullable: true });
readonly typeFilter = new FormControl('', { nonNullable: true });
readonly weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
readonly filteredEvents = computed(() =>
this.events().filter(
(event) =>
(!this.roomFilter.value || event.roomId === this.roomFilter.value) &&
(!this.assigneeFilter.value || event.assigneeUserId === this.assigneeFilter.value) &&
(!this.typeFilter.value || event.type === this.typeFilter.value),
),
);
readonly days = computed(() => {
const cursor = this.cursor();
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const start = new Date(first);
start.setDate(start.getDate() - ((first.getDay() + 6) % 7));
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
const now = new Date();
return {
date,
key: this.key(date),
currentMonth: date.getMonth() === cursor.getMonth(),
today: this.key(date) === this.key(now),
};
});
});
ngOnInit() {
this.projectsApi
.projectMembers(this.projectId())
.subscribe((members) => this.members.set(members.filter((member) => member.active)));
this.api
.rooms(this.projectId(), { pageSize: 100 })
.subscribe((page) => this.rooms.set(page.items));
this.reload();
}
move(offset: number) {
const next = new Date(this.cursor());
next.setMonth(next.getMonth() + offset);
this.cursor.set(next);
this.reload();
}
today() {
this.cursor.set(new Date());
this.reload();
}
eventsFor(key: string) {
return this.filteredEvents().filter((event) => event.date.slice(0, 10) === key);
}
icon(type: CalendarEvent['type']) {
return ({ task_start: '▶', task_due: '✓', milestone: '◆', expense_due: '€' } as const)[type];
}
typeLabel(type: CalendarEvent['type']) {
return (
{
task_start: 'Start',
task_due: 'Fällig',
milestone: 'Meilenstein',
expense_due: 'Zahlung',
} as const
)[type];
}
private reload() {
const cursor = this.cursor();
const from = new Date(cursor.getFullYear(), cursor.getMonth() - 1, 22);
const to = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 8);
this.loading.set(true);
this.api.calendar(this.projectId(), this.key(from), this.key(to)).subscribe({
next: (events) => this.events.set(events),
error: () => this.error.set('Die Kalenderdaten konnten nicht geladen werden.'),
complete: () => this.loading.set(false),
});
}
private key(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,262 @@
import { Component, inject, signal } from '@angular/core';
import type { OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import type {
ApiErrorBody,
ProjectDto,
ProjectInvitationDto,
ProjectMemberDto,
ProjectRole,
} from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
import { UiPageHeaderComponent, UiStatusBadgeComponent } from '../../shared/ui';
const assignableRoles: readonly ProjectRole[] = ['administrator', 'editor', 'reader'];
@Component({
standalone: true,
imports: [ReactiveFormsModule, RouterLink, UiPageHeaderComponent, UiStatusBadgeComponent],
template: `
<a routerLink="/projekte">Zurueck zu den Projekten</a>
@if (project(); as currentProject) {
<ui-page-header
[title]="currentProject.name"
[description]="currentProject.description || 'Keine Beschreibung'"
/>
<p>
Ihre Rolle: <strong>{{ roleLabel(currentProject.role) }}</strong>
</p>
@if (canManage()) {
<section class="ui-card invite-card" aria-labelledby="invite-title">
<h2 id="invite-title">Projektmitglied einladen</h2>
<form class="ui-form invite-form" [formGroup]="inviteForm" (ngSubmit)="invite()">
<label class="ui-form-field">
<span class="ui-label">E-Mail-Adresse</span>
<input class="ui-control" type="email" autocomplete="email" formControlName="email" />
</label>
<label class="ui-form-field">
<span class="ui-label">Projektrolle</span>
<select class="ui-control" formControlName="role">
@for (role of roles; track role) {
<option [value]="role">{{ roleLabel(role) }}</option>
}
</select>
</label>
<button
class="ui-button ui-button--primary"
type="submit"
[disabled]="inviteForm.invalid || saving()"
>
Einladung erstellen
</button>
</form>
@if (createdInvitation(); as invitation) {
<div class="invitation-result" role="status">
<strong>Einladung gespeichert</strong>
<p>
Es ist kein Mailversand konfiguriert. Geben Sie diesen internen Pfad sicher weiter:
</p>
<code>{{ invitation.invitationPath }}</code>
</div>
}
</section>
}
<section class="ui-card members-card" aria-labelledby="members-title">
<h2 id="members-title">Mitglieder</h2>
<div class="member-list">
@for (member of members(); track member.userId) {
<article class="member-row">
<div>
<strong>{{ member.name }}</strong>
<p>{{ member.email || 'Keine E-Mail-Adresse' }}</p>
</div>
<ui-status-badge
[label]="roleLabel(member.role)"
[tone]="member.active ? 'success' : 'neutral'"
/>
@if (canManage() && member.role !== 'owner') {
<label>
<span class="visually-hidden">Rolle fuer {{ member.name }}</span>
<select
class="ui-control"
[value]="member.role"
(change)="changeRole(member, $event)"
>
@for (role of roles; track role) {
<option [value]="role">{{ roleLabel(role) }}</option>
}
</select>
</label>
<button class="ui-button ui-button--danger" type="button" (click)="remove(member)">
Entfernen
</button>
}
</article>
}
</div>
</section>
} @else if (!error()) {
<p>Projekt wird geladen ...</p>
}
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
`,
styles: [
`
:host,
.invite-card,
.members-card,
.member-list,
.invitation-result {
display: grid;
gap: var(--space-4);
}
.invite-card,
.members-card {
margin-top: var(--space-6);
}
.invite-card h2,
.members-card h2,
.member-row p,
.invitation-result p {
margin: 0;
}
.invite-form {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
align-items: end;
}
.invitation-result {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-primary-subtle);
}
code {
overflow-wrap: anywhere;
}
.member-row {
display: grid;
gap: var(--space-3);
padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border);
}
.member-row:last-child {
border-bottom: 0;
}
.member-row p {
color: var(--color-text-secondary);
}
.error {
color: var(--color-danger);
}
@media (min-width: 48rem) {
.member-row {
grid-template-columns: minmax(12rem, 1fr) auto minmax(10rem, auto) auto;
align-items: center;
}
}
`,
],
})
export class ProjectDetailPageComponent implements OnInit {
private readonly api = inject(ApiClientService);
private readonly route = inject(ActivatedRoute);
private projectId = '';
readonly project = signal<ProjectDto | null>(null);
readonly members = signal<ProjectMemberDto[]>([]);
readonly saving = signal(false);
readonly error = signal<string | null>(null);
readonly createdInvitation = signal<ProjectInvitationDto | null>(null);
readonly roles = assignableRoles;
readonly inviteForm = new FormGroup({
email: new FormControl('', {
nonNullable: true,
validators: [
(control) => Validators.required(control),
(control) => Validators.email(control),
],
}),
role: new FormControl<ProjectRole>('reader', { nonNullable: true }),
});
ngOnInit(): void {
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
this.load();
}
canManage(): boolean {
return this.project()?.role === 'owner' || this.project()?.role === 'administrator';
}
invite(): void {
if (this.inviteForm.invalid || this.saving()) return;
this.saving.set(true);
this.error.set(null);
this.createdInvitation.set(null);
this.api.createProjectInvitation(this.projectId, this.inviteForm.getRawValue()).subscribe({
next: (invitation) => {
this.createdInvitation.set(invitation);
this.inviteForm.reset();
},
error: (error: { error?: ApiErrorBody }) => {
this.error.set(error.error?.message ?? 'Die Einladung konnte nicht erstellt werden.');
this.saving.set(false);
},
complete: () => this.saving.set(false),
});
}
changeRole(member: ProjectMemberDto, event: Event): void {
const target = event.target;
if (
!(target instanceof HTMLSelectElement) ||
!assignableRoles.includes(target.value as ProjectRole)
)
return;
this.api
.updateProjectMember(this.projectId, member.userId, target.value as ProjectRole)
.subscribe({
next: (updated) =>
this.members.update((members) =>
members.map((entry) => (entry.userId === updated.userId ? updated : entry)),
),
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error?.message ?? 'Die Rolle konnte nicht geaendert werden.'),
});
}
remove(member: ProjectMemberDto): void {
this.api.removeProjectMember(this.projectId, member.userId).subscribe({
next: () =>
this.members.update((members) => members.filter((entry) => entry.userId !== member.userId)),
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error?.message ?? 'Das Mitglied konnte nicht entfernt werden.'),
});
}
roleLabel(role: ProjectRole): string {
return {
owner: 'Eigentuemer',
administrator: 'Projektadministrator',
editor: 'Bearbeiter',
reader: 'Leser',
}[role];
}
private load(): void {
this.api.project(this.projectId).subscribe({
next: (project) => this.project.set(project),
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error?.message ?? 'Das Projekt konnte nicht geladen werden.'),
});
this.api.projectMembers(this.projectId).subscribe({
next: (members) => this.members.set(members),
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error?.message ?? 'Mitglieder konnten nicht geladen werden.'),
});
}
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { conflictMessage, filterTasks } from './project-workspace.helpers';
describe('project workspace behavior', () => {
it('shows an actionable optimistic concurrency message', () => {
expect(conflictMessage(409, 'Fehler')).toContain('zwischenzeitlich');
});
it('combines status and own-task filters', () => {
const tasks = [
{ status: 'planned', assigneeUserId: 'me' },
{ status: 'done', assigneeUserId: 'me' },
{ status: 'planned', assigneeUserId: 'other' },
];
expect(filterTasks(tasks, 'planned', 'me', true)).toEqual([tasks[0]]);
});
});

View File

@@ -0,0 +1,18 @@
export function conflictMessage(status: number, fallback: string): string {
return status === 409
? 'Dieser Datensatz wurde zwischenzeitlich von einer anderen Person geändert. Lade die aktuellen Daten und übernimm deine Änderungen erneut.'
: fallback;
}
export function filterTasks<T extends { status: string; assigneeUserId: string | null }>(
tasks: readonly T[],
status: string,
currentUserId: string | null,
mine: boolean,
): T[] {
return tasks.filter(
(task) =>
(!status || task.status === status) &&
(!mine || (!!currentUserId && task.assigneeUserId === currentUserId)),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
import { Component, inject, signal } from '@angular/core';
import type { OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import type { ApiErrorBody, ProjectDto } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
import { UiEmptyStateComponent, UiPageHeaderComponent } from '../../shared/ui';
@Component({
standalone: true,
imports: [ReactiveFormsModule, RouterLink, UiEmptyStateComponent, UiPageHeaderComponent],
template: `
<ui-page-header title="Projekte" description="Ihre privaten HausPilot-Projekte" />
<section class="ui-card create-card" aria-labelledby="create-project-title">
<h2 id="create-project-title">Projekt anlegen</h2>
<p>Sie werden serverseitig automatisch als Projekteigentuemer eingetragen.</p>
<form class="ui-form" [formGroup]="form" (ngSubmit)="create()">
<label class="ui-form-field">
<span class="ui-label">Projektname</span>
<input class="ui-control" formControlName="name" maxlength="160" />
</label>
<label class="ui-form-field">
<span class="ui-label">Beschreibung</span>
<textarea class="ui-control" formControlName="description" rows="3"></textarea>
</label>
<button
class="ui-button ui-button--primary"
type="submit"
[disabled]="form.invalid || saving()"
>
{{ saving() ? 'Wird angelegt ...' : 'Projekt anlegen' }}
</button>
</form>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
</section>
@if (loading()) {
<p>Projekte werden geladen ...</p>
} @else if (projects().length === 0) {
<ui-empty-state title="Noch keine Projekte" message="Legen Sie Ihr erstes Projekt an." />
} @else {
<section class="project-grid" aria-label="Projektliste">
@for (project of projects(); track project.id) {
<a class="ui-card project-card" [routerLink]="['/projekte', project.id, 'uebersicht']">
<h2>{{ project.name }}</h2>
<p>{{ project.description || 'Keine Beschreibung' }}</p>
<span>Rolle: {{ roleLabel(project.role) }}</span>
</a>
}
</section>
}
`,
styles: [
`
:host,
.create-card,
.project-grid,
.project-card {
display: grid;
gap: var(--space-4);
}
.create-card {
margin-bottom: var(--space-6);
}
.create-card h2,
.create-card p,
.project-card h2,
.project-card p {
margin: 0;
}
.project-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.project-card {
color: var(--color-text-primary);
text-decoration: none;
}
.project-card:hover {
border-color: var(--color-primary);
}
.project-card p,
.project-card span,
.create-card > p {
color: var(--color-text-secondary);
}
.error {
color: var(--color-danger);
}
`,
],
})
export class ProjectsPageComponent implements OnInit {
private readonly api = inject(ApiClientService);
readonly projects = signal<ProjectDto[]>([]);
readonly loading = signal(true);
readonly saving = signal(false);
readonly error = signal<string | null>(null);
readonly form = new FormGroup({
name: new FormControl('', {
nonNullable: true,
validators: [(control) => Validators.required(control), Validators.maxLength(160)],
}),
description: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(4000)],
}),
});
ngOnInit(): void {
this.load();
}
create(): void {
if (this.form.invalid || this.saving()) return;
this.saving.set(true);
this.error.set(null);
this.api.createProject(this.form.getRawValue()).subscribe({
next: (project) => {
this.projects.update((projects) => [project, ...projects]);
this.form.reset();
},
error: (error: { error?: ApiErrorBody }) => {
this.error.set(error.error?.message ?? 'Das Projekt konnte nicht angelegt werden.');
this.saving.set(false);
},
complete: () => this.saving.set(false),
});
}
roleLabel(role: ProjectDto['role']): string {
return {
owner: 'Eigentuemer',
administrator: 'Projektadministrator',
editor: 'Bearbeiter',
reader: 'Leser',
}[role];
}
private load(): void {
this.api.projects().subscribe({
next: (projects) => this.projects.set(projects),
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error?.message ?? 'Projekte konnten nicht geladen werden.'),
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,797 @@
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import type { OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import type { Observable } from 'rxjs';
import type { ApiErrorBody, ProjectMemberDto } from '@boilerplate/api-client';
import { ApiClientService } from '@boilerplate/api-client';
import { UiEmptyStateComponent, UiStatusBadgeComponent } from '../../shared/ui';
import { AuthService } from '../../core/auth.service';
import { HauspilotApiService } from './hauspilot-api.service';
import type { RenovationTask, Room, TaskDetail, TaskPatch } from './hauspilot-api.service';
import { conflictMessage } from './project-workspace.helpers';
@Component({
standalone: true,
imports: [
CurrencyPipe,
DatePipe,
ReactiveFormsModule,
RouterLink,
UiEmptyStateComponent,
UiStatusBadgeComponent,
],
template: `
<a [routerLink]="['/projekte', projectId, 'aufgaben']">Zurück zu den Aufgaben</a>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
@if (loading()) {
<p role="status">Aufgabe wird geladen …</p>
}
@if (task(); as task) {
<header class="task-header ui-card">
<div>
<p>{{ roomName(task.roomId) }} · {{ task.category }}</p>
<h1>{{ task.title }}</h1>
<div class="badges">
<ui-status-badge
[label]="statusLabel(task.status)"
[tone]="
task.status === 'done'
? 'success'
: task.blockedByDependencies
? 'danger'
: 'neutral'
"
/>
<ui-status-badge
[label]="priorityLabel(task.priority)"
[tone]="task.priority === 'critical' ? 'danger' : 'neutral'"
/>
@if (isOverdue(task)) {
<span class="text-flag">⚠ Überfällig</span>
}
@if (task.blockedByDependencies) {
<span class="text-flag">⛔ Blockiert</span>
}
</div>
</div>
@if (canEdit()) {
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
(click)="editing.set(!editing())"
>
{{ editing() ? 'Abbrechen' : 'Bearbeiten' }}
</button>
@if (task.status !== 'done') {
<button
class="ui-button ui-button--primary"
type="button"
[disabled]="saving()"
(click)="quickComplete()"
>
Als erledigt markieren
</button>
}
</div>
}
</header>
@if (editing()) {
<form class="ui-card form-grid" [formGroup]="form" (ngSubmit)="saveTask()">
<label><span>Titel *</span><input class="ui-control" formControlName="title" /></label>
<label class="wide"
><span>Beschreibung</span
><textarea class="ui-control" rows="5" formControlName="description"></textarea>
</label>
<label
><span>Status</span
><select class="ui-control" formControlName="status">
@for (option of statuses; track option[0]) {
<option [value]="option[0]">{{ option[1] }}</option>
}
</select></label
>
<label
><span>Priorität</span
><select class="ui-control" formControlName="priority">
<option value="low">Niedrig</option>
<option value="normal">Normal</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select></label
>
<label
><span>Kategorie</span><input class="ui-control" formControlName="category"
/></label>
<label
><span>Raum</span
><select class="ui-control" formControlName="roomId">
<option value="">Allgemein</option>
@for (room of rooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
<label
><span>Verantwortlich</span
><select class="ui-control" formControlName="assigneeUserId">
<option value="">Nicht zugewiesen</option>
@for (member of assignableMembers(); track member.userId) {
<option [value]="member.userId">{{ member.name }}</option>
}
</select></label
>
<label
><span>Geplanter Start</span
><input class="ui-control" type="date" formControlName="plannedStartDate"
/></label>
<label
><span>Fällig</span><input class="ui-control" type="date" formControlName="dueDate"
/></label>
<label
><span>Aufwand (Stunden)</span
><input
class="ui-control"
type="number"
min="0"
step="0.25"
formControlName="estimatedEffortHours"
/></label>
<label
><span>Geschätzte Kosten</span
><input
class="ui-control"
type="number"
min="0"
step="0.01"
formControlName="estimatedCost"
/></label>
<label
><span>Tatsächliche Kosten</span
><input
class="ui-control"
type="number"
min="0"
step="0.01"
formControlName="actualCost"
/></label>
<label
><span>Fortschrittsgewicht</span
><input class="ui-control" type="number" min="0.01" step="0.1" formControlName="weight"
/></label>
<label class="wide"
><span>Blockierungsgrund</span
><textarea class="ui-control" formControlName="blockingReason"></textarea>
</label>
<div class="wide actions">
<button class="ui-button ui-button--primary" [disabled]="form.invalid || saving()">
{{ saving() ? 'Speichert …' : 'Änderungen speichern' }}
</button>
</div>
</form>
} @else {
<section class="ui-card detail-grid">
<div class="wide">
<h2>Beschreibung</h2>
<p class="prewrap">{{ task.description || 'Keine Beschreibung hinterlegt.' }}</p>
</div>
<div>
<span class="label">Verantwortlich</span
><strong>{{ task.assigneeName || 'Nicht zugewiesen' }}</strong>
</div>
<div>
<span class="label">Start</span
><strong>{{
task.plannedStartDate ? (task.plannedStartDate | date: 'dd.MM.yyyy') : ''
}}</strong>
</div>
<div>
<span class="label">Fälligkeit</span
><strong>{{ task.dueDate ? (task.dueDate | date: 'dd.MM.yyyy') : '' }}</strong>
</div>
<div>
<span class="label">Abgeschlossen</span
><strong>{{
task.actualCompletionDate ? (task.actualCompletionDate | date: 'dd.MM.yyyy') : ''
}}</strong>
</div>
<div>
<span class="label">Aufwand</span
><strong>{{ task.estimatedEffortHours || '' }} h</strong>
</div>
<div>
<span class="label">Kosten</span
><strong>{{ +(task.actualCost || task.estimatedCost || 0) | currency: 'EUR' }}</strong>
</div>
@if (task.blockingReason) {
<div class="wide">
<span class="label">Blockierungsgrund</span><strong>{{ task.blockingReason }}</strong>
</div>
}
</section>
}
<div class="columns">
<section class="ui-card">
<h2>Checkliste</h2>
<progress max="100" [value]="checklistProgress()">{{ checklistProgress() }} %</progress>
<ul class="plain-list">
@for (item of task.checklist; track item.id) {
<li>
<label
><input
type="checkbox"
[checked]="item.completed"
[disabled]="!canEdit() || saving()"
(change)="toggleChecklist(item.id, !item.completed)"
/>
<span [class.completed]="item.completed">{{ item.text }}</span></label
>
</li>
}
</ul>
@if (canEdit()) {
<form class="inline-form" (ngSubmit)="addChecklist()">
<input
class="ui-control"
[formControl]="checklistText"
placeholder="Neuer Eintrag"
aria-label="Neuer Checklisteneintrag"
/><button
class="ui-button ui-button--secondary"
[disabled]="checklistText.invalid || saving()"
>
Hinzufügen
</button>
</form>
}
</section>
<section class="ui-card">
<h2>Abhängigkeiten</h2>
<h3>Vorgänger</h3>
@for (dep of predecessors(); track dep.id) {
<div class="dependency">
<a [routerLink]="['/projekte', projectId, 'aufgaben', dep.predecessorTaskId]">{{
dep.predecessor?.title || dep.predecessorTaskId
}}</a
><span>{{ statusLabel(dep.predecessor?.status || '') }}</span>
@if (canEdit()) {
<button type="button" class="link-button" (click)="removeDependency(dep.id)">
Entfernen
</button>
}
</div>
} @empty {
<p>Keine Vorgänger.</p>
}
<h3>Nachfolger</h3>
@for (dep of successors(); track dep.id) {
<div class="dependency">
<a [routerLink]="['/projekte', projectId, 'aufgaben', dep.successorTaskId]">{{
dep.successor?.title || dep.successorTaskId
}}</a
><span>{{ statusLabel(dep.successor?.status || '') }}</span>
</div>
} @empty {
<p>Keine Nachfolger.</p>
}
@if (canEdit()) {
<form class="inline-form" (ngSubmit)="addDependency()">
<select class="ui-control" [formControl]="predecessorId">
<option value="">Vorgänger auswählen</option>
@for (candidate of dependencyCandidates(); track candidate.id) {
<option [value]="candidate.id">{{ candidate.title }}</option>
}</select
><button
class="ui-button ui-button--secondary"
[disabled]="!predecessorId.value || saving()"
>
Verknüpfen
</button>
</form>
}
</section>
</div>
<section class="ui-card" id="comments">
<h2>Kommentare</h2>
<div class="comments">
@for (comment of task.comments; track comment.id) {
<article [attr.id]="'comment-' + comment.id">
<div>
<strong>{{ comment.authorName }}</strong>
<time>{{ comment.createdAt | date: 'dd.MM.yyyy, HH:mm' }}</time>
</div>
<p class="prewrap">{{ comment.text }}</p>
</article>
} @empty {
<ui-empty-state
title="Noch keine Kommentare"
description="Halten Sie Entscheidungen und Rückfragen direkt an der Aufgabe fest."
/>
}
</div>
@if (canEdit()) {
<form class="comment-form" (ngSubmit)="addComment()">
<label
><span>Kommentar</span
><textarea
class="ui-control"
rows="4"
[formControl]="commentText"
placeholder="Mit @ ein Projektmitglied erwähnen"
></textarea>
</label>
<div>
<span class="label">Erwähnen</span>
<div class="mention-list">
@for (member of members(); track member.userId) {
<button
type="button"
class="mention"
[class.selected]="selectedMentions().includes(member.userId)"
(click)="toggleMention(member)"
>
&#64;{{ member.name }}
</button>
}
</div>
</div>
<button
class="ui-button ui-button--primary"
[disabled]="commentText.invalid || saving()"
>
Kommentar senden
</button>
</form>
}
</section>
<div class="columns">
<section class="ui-card">
<h2>Dokumente</h2>
@for (document of task.documents; track document.id) {
<div class="dependency">
<span>{{ document.title }}</span
><a [href]="api.downloadUrl(projectId, document.id)">Herunterladen</a>
</div>
} @empty {
<p>Noch keine Dokumente zugeordnet.</p>
}
</section>
<section class="ui-card">
<h2>Aktivitäten</h2>
@for (activity of task.activities; track activity.id) {
<div class="activity">
<strong>{{ activity.actorName }}</strong
><span>{{ activity.action }}</span
><time>{{ activity.createdAt | date: 'dd.MM.yyyy, HH:mm' }}</time>
</div>
} @empty {
<p>Noch keine aufgabenbezogenen Aktivitäten.</p>
}
</section>
</div>
}
`,
styles: [
`
:host {
display: grid;
gap: var(--space-5);
}
.task-header,
.actions,
.badges,
.dependency,
.activity,
.inline-form {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.task-header {
justify-content: space-between;
}
.task-header h1 {
margin-block: var(--space-1) var(--space-3);
}
.task-header p,
.prewrap {
margin: 0;
}
.text-flag {
font-weight: 700;
color: var(--color-danger);
}
.form-grid,
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
label {
display: grid;
gap: var(--space-2);
}
.wide {
grid-column: 1 / -1;
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.label {
display: block;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.prewrap {
white-space: pre-wrap;
}
progress {
inline-size: 100%;
accent-color: var(--color-primary);
}
.plain-list {
list-style: none;
padding: 0;
display: grid;
gap: var(--space-2);
}
.plain-list label {
display: flex;
align-items: center;
}
.completed {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.dependency {
justify-content: space-between;
border-block-end: 1px solid var(--color-border);
padding-block: var(--space-2);
}
.link-button {
border: 0;
background: transparent;
color: var(--color-danger);
text-decoration: underline;
cursor: pointer;
}
.comments {
display: grid;
gap: var(--space-3);
}
.comments article {
border-inline-start: 0.25rem solid var(--color-border);
padding-inline-start: var(--space-3);
}
.comments time,
.activity time {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.comment-form {
display: grid;
gap: var(--space-3);
margin-block-start: var(--space-4);
}
.mention-list {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.mention {
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
.mention.selected {
border-color: var(--color-primary);
background: var(--color-primary-subtle);
}
.activity {
align-items: baseline;
border-block-end: 1px solid var(--color-border);
padding-block: var(--space-2);
}
.error {
color: var(--color-danger);
}
@media (max-width: 48rem) {
.form-grid,
.detail-grid,
.columns {
grid-template-columns: 1fr;
}
.wide {
grid-column: auto;
}
.actions .ui-button {
inline-size: 100%;
}
}
`,
],
})
export class TaskDetailPageComponent implements OnInit {
readonly api = inject(HauspilotApiService);
private readonly projectsApi = inject(ApiClientService);
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
projectId = '';
taskId = '';
readonly task = signal<TaskDetail | null>(null);
readonly rooms = signal<Room[]>([]);
readonly allTasks = signal<RenovationTask[]>([]);
readonly members = signal<ProjectMemberDto[]>([]);
readonly loading = signal(true);
readonly saving = signal(false);
readonly editing = signal(false);
readonly error = signal<string | null>(null);
readonly selectedMentions = signal<string[]>([]);
readonly checklistText = new FormControl('', {
nonNullable: true,
validators: [
(control) => Validators.required(control),
(control) => Validators.maxLength(500)(control),
],
});
readonly predecessorId = new FormControl('', { nonNullable: true });
readonly commentText = new FormControl('', {
nonNullable: true,
validators: [
(control) => Validators.required(control),
(control) => Validators.maxLength(4000)(control),
],
});
readonly statuses = [
['idea', 'Idee'],
['planned', 'Geplant'],
['commissioned', 'Beauftragt'],
['in_progress', 'In Arbeit'],
['blocked', 'Blockiert'],
['acceptance', 'Abnahme erforderlich'],
['done', 'Erledigt'],
['omitted', 'Entfällt'],
] as const;
readonly form = new FormGroup({
title: new FormControl('', {
nonNullable: true,
validators: [
(control) => Validators.required(control),
(control) => Validators.maxLength(200)(control),
],
}),
description: new FormControl('', { nonNullable: true }),
category: new FormControl('general', {
nonNullable: true,
validators: [(control) => Validators.required(control)],
}),
status: new FormControl('planned', { nonNullable: true }),
priority: new FormControl('normal', { nonNullable: true }),
roomId: new FormControl('', { nonNullable: true }),
assigneeUserId: new FormControl('', { nonNullable: true }),
plannedStartDate: new FormControl('', { nonNullable: true }),
dueDate: new FormControl('', { nonNullable: true }),
estimatedEffortHours: new FormControl<number | null>(null),
estimatedCost: new FormControl<number | null>(null),
actualCost: new FormControl<number | null>(null),
weight: new FormControl(1, { nonNullable: true, validators: [Validators.min(0.01)] }),
blockingReason: new FormControl('', { nonNullable: true }),
});
readonly assignableMembers = computed(() =>
this.members().filter((member) => member.active && member.role !== 'reader'),
);
readonly predecessors = computed(
() => this.task()?.dependencies.filter((item) => item.successorTaskId === this.taskId) ?? [],
);
readonly successors = computed(
() => this.task()?.dependencies.filter((item) => item.predecessorTaskId === this.taskId) ?? [],
);
readonly dependencyCandidates = computed(() =>
this.allTasks().filter(
(item) =>
item.id !== this.taskId &&
!this.predecessors().some((dep) => dep.predecessorTaskId === item.id),
),
);
ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
this.taskId = this.route.snapshot.paramMap.get('taskId') ?? '';
this.load();
}
canEdit() {
return this.members().some(
(member) =>
member.userId === this.auth.user()?.id && member.active && member.role !== 'reader',
);
}
roomName(id: string | null) {
return this.rooms().find((room) => room.id === id)?.name ?? 'Allgemeine Aufgabe';
}
isOverdue(task: RenovationTask) {
return (
!!task.dueDate &&
!['done', 'omitted'].includes(task.status) &&
new Date(task.dueDate) < new Date()
);
}
statusLabel(value: string) {
return this.statuses.find((item) => item[0] === value)?.[1] ?? value;
}
priorityLabel(value: string) {
return (
(
{ low: 'Niedrig', normal: 'Normal', high: 'Hoch', critical: 'Kritisch' } as Record<
string,
string
>
)[value] ?? value
);
}
checklistProgress() {
const items = this.task()?.checklist ?? [];
return items.length
? Math.round((items.filter((item) => item.completed).length / items.length) * 100)
: 0;
}
quickComplete() {
const task = this.task();
if (task) this.updateTask({ status: 'done' });
}
saveTask() {
if (this.form.invalid) return;
const value = this.form.getRawValue();
this.updateTask({
...value,
roomId: value.roomId || null,
assigneeUserId: value.assigneeUserId || null,
plannedStartDate: value.plannedStartDate || null,
dueDate: value.dueDate || null,
});
}
toggleChecklist(itemId: string, completed: boolean) {
this.run(this.api.updateChecklist(this.projectId, this.taskId, itemId, { completed }), () =>
this.reloadTask(),
);
}
addChecklist() {
if (this.checklistText.invalid) return;
this.run(this.api.addChecklist(this.projectId, this.taskId, this.checklistText.value), () => {
this.checklistText.reset();
this.reloadTask();
});
}
addDependency() {
if (!this.predecessorId.value) return;
this.run(this.api.addDependency(this.projectId, this.taskId, this.predecessorId.value), () => {
this.predecessorId.reset();
this.reloadTask();
});
}
removeDependency(id: string) {
this.run(this.api.removeDependency(this.projectId, this.taskId, id), () => this.reloadTask());
}
toggleMention(member: ProjectMemberDto) {
this.selectedMentions.update((items) =>
items.includes(member.userId)
? items.filter((id) => id !== member.userId)
: [...items, member.userId],
);
if (!this.commentText.value.includes(`@${member.name}`))
this.commentText.setValue(
`${this.commentText.value}${this.commentText.value ? ' ' : ''}@${member.name} `,
);
}
addComment() {
if (this.commentText.invalid) return;
this.run(
this.api.addComment(
this.projectId,
this.taskId,
this.commentText.value,
this.selectedMentions(),
),
(comment) => {
this.task.update((task) =>
task
? {
...task,
comments: [
...task.comments,
{ ...comment, authorName: this.auth.user()?.name ?? 'Ich' },
],
}
: task,
);
this.commentText.reset();
this.selectedMentions.set([]);
},
);
}
private updateTask(patch: TaskPatch) {
const task = this.task();
if (!task) return;
this.run(this.api.updateTask(this.projectId, task, patch), () => {
this.editing.set(false);
this.reloadTask();
});
}
private load() {
this.projectsApi.projectMembers(this.projectId).subscribe({
next: (members) => this.members.set(members),
error: (error: unknown) => this.fail(error),
});
this.api.rooms(this.projectId, { pageSize: 100 }).subscribe({
next: (page) => this.rooms.set(page.items),
error: (error: unknown) => this.fail(error),
});
this.api.tasks(this.projectId, { pageSize: 100, sortBy: 'title' }).subscribe({
next: (page) => this.allTasks.set(page.items),
error: (error: unknown) => this.fail(error),
});
this.reloadTask();
}
private reloadTask() {
this.api.task(this.projectId, this.taskId).subscribe({
next: (task) => {
this.task.set(task);
this.form.reset({
title: task.title,
description: task.description ?? '',
category: task.category,
status: task.status,
priority: task.priority,
roomId: task.roomId ?? '',
assigneeUserId: task.assigneeUserId ?? '',
plannedStartDate: task.plannedStartDate?.slice(0, 10) ?? '',
dueDate: task.dueDate?.slice(0, 10) ?? '',
estimatedEffortHours: task.estimatedEffortHours ? +task.estimatedEffortHours : null,
estimatedCost: task.estimatedCost ? +task.estimatedCost : null,
actualCost: task.actualCost ? +task.actualCost : null,
weight: +task.weight,
blockingReason: task.blockingReason ?? '',
});
this.loading.set(false);
},
error: (error: unknown) => this.fail(error),
});
}
private run<T>(request: Observable<T>, next: (value: T) => void) {
this.saving.set(true);
this.error.set(null);
request.subscribe({
next,
error: (error: { status?: number; error?: ApiErrorBody }) => {
this.error.set(
conflictMessage(
error.status ?? 0,
error.error?.message ?? 'Die Änderung konnte nicht gespeichert werden.',
),
);
this.saving.set(false);
},
complete: () => this.saving.set(false),
});
}
private fail(error: unknown) {
this.error.set(
(error as { error?: ApiErrorBody }).error?.message ??
'Die Aufgabe konnte nicht geladen werden.',
);
this.loading.set(false);
}
}

View File

@@ -30,7 +30,7 @@ interface NavItem {
@if (auth.user()) {
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
}
<strong class="brand">Business App</strong>
<strong class="brand">HausPilot</strong>
@if (auth.user()) {
<button
class="ui-icon-button notification-button"
@@ -47,7 +47,7 @@ interface NavItem {
</button>
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
} @else {
<a class="ui-button ui-button--primary logout" href="/api/auth/login">Anmelden</a>
<a class="ui-button ui-button--primary logout" [href]="loginHref()">Anmelden</a>
}
@if (auth.user() && notificationPanelOpen()) {
<app-notification-panel
@@ -93,7 +93,7 @@ interface NavItem {
<section class="login-panel">
<h1>Anmelden</h1>
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
<a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
<a class="ui-button ui-button--primary ui-button--mobile-full" [href]="loginHref()"
>Mit OIDC anmelden</a
>
</section>
@@ -243,6 +243,8 @@ export class AppShellComponent {
);
readonly nav: NavItem[] = [
{ label: 'Dashboard', path: '/' },
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
{ label: 'Profil', path: '/profil' },
{ label: 'Sicherheit', path: '/account/security' },
{
@@ -273,6 +275,11 @@ export class AppShellComponent {
return !item.permission || this.auth.has(item.permission);
}
loginHref(): string {
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
}
toggleNotifications(): void {
this.notificationPanelOpen.update((open) => !open);
if (this.notificationPanelOpen()) {