generated from bastian/boilerplate
798 lines
26 KiB
TypeScript
798 lines
26 KiB
TypeScript
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);
|
||
}
|
||
}
|