Files
Bonsai-webapp/src/app/milestones/milestone-detail/milestone-detail.ts
T
2026-05-30 08:32:08 +02:00

321 lines
11 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 { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { marked } from 'marked';
import { IssueEntity, IssuesStore } from '../../issues/issues.store';
import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../milestones.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
@Component({
selector: 'app-milestone-detail',
imports: [FormsModule, GanttDiagram],
templateUrl: './milestone-detail.html',
styleUrl: './milestone-detail.css',
})
export class MilestoneDetail {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly milestonesStore = inject(MilestonesStore);
private readonly issuesStore = inject(IssuesStore);
private readonly sanitizer = inject(DomSanitizer);
private readonly statusesStore = inject(StatusesStore);
protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
protected milestone: MilestoneEntity = this.buildMilestone();
protected readonly issues = this.issuesStore.issues;
protected editingDescription = false;
protected showAddIssue = false;
protected showCreateIssue = false;
protected newIssueName = '';
protected issueSearchQuery = '';
protected showIssueSuggestions = false;
protected moreMenuOpen = false;
protected showAddDependency = false;
protected selectedCandidateMilestoneId: number | null = null;
constructor() {
this.milestonesStore.load().then(() => {
if (!this.isNewRoute) {
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
const found = this.milestonesStore.getById(id);
if (found) this.milestone = { ...found };
}
});
this.issuesStore.load();
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const id = Number(params.get('id'));
if (!id || isNaN(id)) return;
const found = this.milestonesStore.getById(id);
if (found) {
this.milestone = { ...found };
this.editingDescription = false;
this.showAddIssue = false;
this.showCreateIssue = false;
this.newIssueName = '';
}
});
}
protected get hasDependencies(): boolean {
return this.milestone.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] {
return this.milestone.dependsOnIds;
}
protected get availableCandidates(): MilestoneEntity[] {
return this.milestonesStore.milestones().filter(
(m) => m.id !== this.milestone.id && !this.milestone.dependsOnIds.includes(m.id),
);
}
protected resolveDependency(id: number): MilestoneEntity | undefined {
return this.milestonesStore.getById(id);
}
protected openAddDependency(): void {
this.selectedCandidateMilestoneId = null;
this.showAddDependency = true;
}
protected cancelAddDependency(): void {
this.showAddDependency = false;
this.selectedCandidateMilestoneId = null;
}
protected async confirmAddDependency(): Promise<void> {
if (this.selectedCandidateMilestoneId === null) return;
this.milestone.dependsOnIds = [...this.milestone.dependsOnIds, this.selectedCandidateMilestoneId];
this.selectedCandidateMilestoneId = null;
this.showAddDependency = false;
await this.saveMilestone();
}
protected async removeDependency(id: number): Promise<void> {
this.milestone.dependsOnIds = this.milestone.dependsOnIds.filter((depId) => depId !== id);
await this.saveMilestone();
}
protected get linkedIssues(): IssueEntity[] {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
}
protected get displayedIssues(): IssueEntity[] {
const epicNamesInMilestone = new Set(
this.linkedIssues.filter((i) => i.type === 'Epic').map((i) => i.name),
);
return this.linkedIssues.filter((i) => !epicNamesInMilestone.has(i.epic));
}
protected get availableIssues(): IssueEntity[] {
const assignedElsewhere = new Set(
this.milestonesStore.milestones()
.filter((m) => m.id !== this.milestone.id)
.flatMap((m) => m.issueIds),
);
const epicNamesInMilestone = new Set(
this.issues()
.filter((i) => i.type === 'Epic' && this.milestone.issueIds.includes(i.id))
.map((i) => i.name),
);
return this.issues().filter(
(i) =>
!this.milestone.issueIds.includes(i.id) &&
!assignedElsewhere.has(i.id) &&
!epicNamesInMilestone.has(i.epic),
);
}
protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.milestone.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected get milestoneGanttTasks(): GanttTask[] {
const tasks: GanttTask[] = [];
for (const issue of this.linkedIssues) {
if (!issue.startDate || !issue.endDate) continue;
tasks.push({
id: `issue-${issue.id}`,
name: `#${issue.id} ${issue.name}`,
start: issue.startDate,
end: issue.endDate,
progress: issue.progress,
});
}
return tasks;
}
protected get totalEstimatedTime(): number | null {
const times = this.linkedIssues
.filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null)
.map((i) => i.estimatedTime);
return times.length === 0 ? null : times.reduce((a, b) => a + b, 0);
}
protected get progress(): number {
if (this.linkedIssues.length === 0) return 0;
return Math.round(
(this.linkedIssues.filter((i) => i.status === 'done').length / this.linkedIssues.length) * 100,
);
}
protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
const map: Record<IssueEntity['type'], { letter: string; bg: string }> = {
Epic: { letter: 'E', bg: '#7c3aed' },
Bug: { letter: 'B', bg: '#dc2626' },
Story: { letter: 'S', bg: '#16a34a' },
Task: { letter: 'T', bg: '#2563eb' },
Study: { letter: 'St', bg: '#6b7280' },
'Technical Story':{ letter: 'TS', bg: '#d97706' },
};
return map[type] ?? { letter: '?', bg: '#6b7280' };
}
protected get issueSuggestions(): IssueEntity[] {
const q = this.issueSearchQuery.trim().toLowerCase();
if (!q) return this.availableIssues.slice(0, 8);
return this.availableIssues.filter(
(i) => i.name.toLowerCase().includes(q) || String(i.id).includes(q),
).slice(0, 8);
}
protected openCreateIssue(): void {
this.newIssueName = '';
this.showCreateIssue = true;
this.showAddIssue = false;
}
protected cancelCreateIssue(): void {
this.showCreateIssue = false;
this.newIssueName = '';
}
protected async confirmCreateIssue(): Promise<void> {
const name = this.newIssueName.trim();
if (!name) return;
const created = await this.issuesStore.upsert({
id: 0,
type: 'Story',
assignee: '',
epic: '',
name,
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
});
this.milestone.issueIds = [...this.milestone.issueIds, created.id];
await this.saveMilestone();
this.showCreateIssue = false;
this.newIssueName = '';
}
protected openAddIssue(): void {
this.issueSearchQuery = '';
this.showIssueSuggestions = false;
this.showAddIssue = true;
this.showCreateIssue = false;
}
protected cancelAddIssue(): void {
this.showAddIssue = false;
this.issueSearchQuery = '';
this.showIssueSuggestions = false;
}
protected async addIssueFromSearch(id: number): Promise<void> {
this.milestone.issueIds = [...this.milestone.issueIds, id];
this.issueSearchQuery = '';
this.showIssueSuggestions = false;
this.showAddIssue = false;
await this.saveMilestone();
}
protected hideIssueSuggestions(): void {
setTimeout(() => { this.showIssueSuggestions = false; }, 150);
}
protected statusBadge(status: IssueEntity['status']): StatusEntity {
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
}
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } {
const map: Record<IssueEntity['priority'], { symbol: string; color: string }> = {
'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545' },
'HAUTE': { symbol: '↑', color: '#fd7e14' },
'MOYENNE': { symbol: '', color: '#ffc107' },
'BASSE': { symbol: '↓', color: '#0d6efd' },
'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d' },
};
return map[priority] ?? { symbol: '?', color: '#6c757d' };
}
protected async removeIssue(id: number): Promise<void> {
this.milestone.issueIds = this.milestone.issueIds.filter((i) => i !== id);
await this.saveMilestone();
}
protected onDescriptionPaste(event: ClipboardEvent): void {
const ta = event.target as HTMLTextAreaElement;
const start = ta.selectionStart;
const end = ta.selectionEnd;
handleImagePaste(event, (md) => {
this.milestone.description = insertAtSelection(ta, this.milestone.description, start, end, md);
});
}
protected async saveMilestone(explicit = false): Promise<void> {
if (this.isNewRoute && !explicit) return;
if (!this.milestone.name.trim()) return;
const saved = await this.milestonesStore.upsert(this.milestone);
this.milestone = { ...saved };
if (this.isNewRoute) {
this.router.navigate(['/milestones', saved.id]);
}
}
protected cancelCreation(): void {
this.router.navigate(['/milestones']);
}
protected async deleteMilestone(): Promise<void> {
await this.milestonesStore.deleteById(this.milestone.id);
this.router.navigate(['/milestones']);
}
protected toggleMoreMenu(): void {
this.moreMenuOpen = !this.moreMenuOpen;
}
protected closeMoreMenu(): void {
this.moreMenuOpen = false;
}
protected navigateToIssue(id: number): void {
this.router.navigate(['/issues', id]);
}
private buildMilestone(): MilestoneEntity {
if (this.route.snapshot.routeConfig?.path === 'milestones/new') {
return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
}
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
}
}