Files
flat-pilot/apps/frontend/src/app/features/projects/task-detail.page.ts
Bastian Wagner e62673ac11 mvp
2026-07-20 09:01:36 +02:00

798 lines
26 KiB
TypeScript
Raw Blame History

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