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()) {
{{ error() }}
}
@if (loading()) {
Aufgabe wird geladen …
}
@if (task(); as task) {
@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 }}
}
}
Abhängigkeiten
Vorgänger
@for (dep of predecessors(); track dep.id) {
} @empty {
Keine Vorgänger.
}
Nachfolger
@for (dep of successors(); track dep.id) {
} @empty {
Keine Nachfolger.
}
@if (canEdit()) {
}
Dokumente
@for (document of task.documents; track document.id) {
} @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);
}
}
{{ comment.text }}