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 showCreateIssue = false; protected newIssueName = ''; 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; this.showCreateIssue = false; this.newIssueName = ''; } }); } 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 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 = { 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 { const name = this.newIssueName.trim(); if (!name) return; const created = await this.issuesStore.upsert({ id: 0, type: 'Story', assignee: '', epic: '', name, 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 { 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 = { 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 = { '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 { 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 { 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 { 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: [] }; } }