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: ` Zurück zu den Aufgaben @if (error()) { } @if (loading()) {

Aufgabe wird geladen …

} @if (task(); as task) {

{{ roomName(task.roomId) }} · {{ task.category }}

{{ task.title }}

@if (isOverdue(task)) { ⚠ Überfällig } @if (task.blockedByDependencies) { ⛔ Blockiert }
@if (canEdit()) {
@if (task.status !== 'done') { }
}
@if (editing()) {
} @else {

Beschreibung

{{ task.description || 'Keine Beschreibung hinterlegt.' }}

Verantwortlich{{ task.assigneeName || 'Nicht zugewiesen' }}
Start{{ task.plannedStartDate ? (task.plannedStartDate | date: 'dd.MM.yyyy') : '–' }}
Fälligkeit{{ task.dueDate ? (task.dueDate | date: 'dd.MM.yyyy') : '–' }}
Abgeschlossen{{ task.actualCompletionDate ? (task.actualCompletionDate | date: 'dd.MM.yyyy') : '–' }}
Aufwand{{ task.estimatedEffortHours || '–' }} h
Kosten{{ +(task.actualCost || task.estimatedCost || 0) | currency: 'EUR' }}
@if (task.blockingReason) {
Blockierungsgrund{{ task.blockingReason }}
}
}

Checkliste

{{ checklistProgress() }} % @if (canEdit()) {
}

Abhängigkeiten

Vorgänger

@for (dep of predecessors(); track dep.id) {
{{ dep.predecessor?.title || dep.predecessorTaskId }}{{ statusLabel(dep.predecessor?.status || '') }} @if (canEdit()) { }
} @empty {

Keine Vorgänger.

}

Nachfolger

@for (dep of successors(); track dep.id) {
{{ dep.successor?.title || dep.successorTaskId }}{{ statusLabel(dep.successor?.status || '') }}
} @empty {

Keine Nachfolger.

} @if (canEdit()) {
}

Kommentare

@for (comment of task.comments; track comment.id) {
{{ comment.authorName }}

{{ comment.text }}

} @empty { }
@if (canEdit()) {
Erwähnen
@for (member of members(); track member.userId) { }
}

Dokumente

@for (document of task.documents; track document.id) {
{{ document.title }}Herunterladen
} @empty {

Noch keine Dokumente zugeordnet.

}

Aktivitäten

@for (activity of task.activities; track activity.id) {
{{ activity.actorName }}{{ activity.action }}
} @empty {

Noch keine aufgabenbezogenen Aktivitäten.

}
} `, 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(null); readonly rooms = signal([]); readonly allTasks = signal([]); readonly members = signal([]); readonly loading = signal(true); readonly saving = signal(false); readonly editing = signal(false); readonly error = signal(null); readonly selectedMentions = signal([]); 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(null), estimatedCost: new FormControl(null), actualCost: new FormControl(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(request: Observable, 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); } }