generated from bastian/boilerplate
mvp
This commit is contained in:
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal file
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal 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)"
|
||||
>
|
||||
@{{ 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user