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

View File

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