generated from bastian/boilerplate
mvp
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/frontend/src/app/core/session-expiry.interceptor.ts
Normal file
21
apps/frontend/src/app/core/session-expiry.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
568
apps/frontend/src/app/features/projects/hauspilot-api.service.ts
Normal file
568
apps/frontend/src/app/features/projects/hauspilot-api.service.ts
Normal 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 }) });
|
||||
}
|
||||
}
|
||||
225
apps/frontend/src/app/features/projects/invitations.page.ts
Normal file
225
apps/frontend/src/app/features/projects/invitations.page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
262
apps/frontend/src/app/features/projects/project-detail.page.ts
Normal file
262
apps/frontend/src/app/features/projects/project-detail.page.ts
Normal 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.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
1283
apps/frontend/src/app/features/projects/project-workspace.page.ts
Normal file
1283
apps/frontend/src/app/features/projects/project-workspace.page.ts
Normal file
File diff suppressed because it is too large
Load Diff
150
apps/frontend/src/app/features/projects/projects.page.ts
Normal file
150
apps/frontend/src/app/features/projects/projects.page.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal file
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal 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)"
|
||||
>
|
||||
@{{ 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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user