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

201 lines
7.3 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';
@Component({
selector: 'app-milestone-detail',
imports: [FormsModule],
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);
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 issueSearchQuery = '';
protected showIssueSuggestions = false;
protected moreMenuOpen = false;
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;
}
});
}
protected get linkedIssues(): IssueEntity[] {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
}
protected get availableIssues(): IssueEntity[] {
const assignedElsewhere = new Set(
this.milestonesStore.milestones()
.filter((m) => m.id !== this.milestone.id)
.flatMap((m) => m.issueIds),
);
return this.issues().filter(
(i) => !this.milestone.issueIds.includes(i.id) && !assignedElsewhere.has(i.id),
);
}
protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.milestone.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
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 openAddIssue(): void {
this.issueSearchQuery = '';
this.showIssueSuggestions = false;
this.showAddIssue = true;
}
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']): { label: string; bg: string; color: string } {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' },
todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' },
'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' },
done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' },
};
return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' };
}
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: '', dueDate: '', issueIds: [] };
}
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] };
}
}