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.store'; import { IssueComments } from '../issue-comments/issue-comments'; import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store'; import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; @Component({ selector: 'app-issue-detail', imports: [FormsModule, IssueComments, GanttDiagram], templateUrl: './issue-detail.html', styleUrl: './issue-detail.css', }) export class IssueDetail { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly issuesStore = inject(IssuesStore); private readonly milestonesStore = inject(MilestonesStore); private readonly statusesStore = inject(StatusesStore); private readonly sanitizer = inject(DomSanitizer); protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; protected issue: IssueEntity = this.buildIssue(); protected readonly issues = this.issuesStore.issues; protected readonly milestones = this.milestonesStore.milestones; protected moreMenuOpen = false; protected statusMenuOpen = false; constructor() { const idParam = this.route.snapshot.paramMap.get('id'); const safeId = Number(idParam ?? 0); this.milestonesStore.load(); this.issuesStore.load().then(() => { if (safeId) { const found = this.issuesStore.getById(safeId); if (found) this.issue = { ...found }; } }); this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { const id = Number(params.get('id')); if (!id || isNaN(id)) return; const existingIssue = this.issuesStore.getById(id); if (existingIssue) { this.issue = { ...existingIssue }; this.editingDescription = false; this.showAddDependency = false; this.showAddToEpic = false; this.showCreateInEpic = false; } }); } protected showAddDependency = false; protected selectedCandidateId: number | null = null; protected editingDescription = false; private _descriptionBeforeEdit = ''; protected showAddToEpic = false; protected selectedEpicCandidateId: number | null = null; protected showCreateInEpic = false; protected newIssueName = ''; protected readonly statusOptions = this.statusesStore.statuses; protected readonly typeOptions: IssueEntity['type'][] = [ 'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story', ]; protected get dependencyIds(): number[] { return this.issue.dependsOnIds; } protected get availableCandidates(): IssueEntity[] { return this.issues().filter( (issue) => issue.id !== this.issue.id && !this.issue.dependsOnIds.includes(issue.id), ); } protected resolveDependency(id: number): IssueEntity | undefined { return this.issues().find((issue) => issue.id === id); } protected openAddDependency(): void { this.selectedCandidateId = null; this.showAddDependency = true; } protected cancelAddDependency(): void { this.showAddDependency = false; this.selectedCandidateId = null; } protected async confirmAddDependency(): Promise { if (this.selectedCandidateId !== null) { this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId]; await this.saveIssue(); } this.showAddDependency = false; this.selectedCandidateId = null; } protected async removeDependency(id: number): Promise { this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id); await this.saveIssue(); } protected get estimatedTimeValue(): number | null { return this.issue.estimatedTime; } protected set estimatedTimeValue(value: number | null) { this.issue.estimatedTime = value === null || value === undefined ? null : Number(value); this.recalculateEndDate(); } private recalculateEndDate(): void { const { startDate, estimatedTime } = this.issue; if (!startDate || estimatedTime === null || estimatedTime <= 0) { this.issue.endDate = ''; return; } const start = new Date(startDate); const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1); start.setDate(start.getDate() + extraDays); this.issue.endDate = start.toISOString().split('T')[0]; } protected onStartDateBlur(): void { this.recalculateEndDate(); this.saveIssue(); } protected get issueTypeValue(): IssueEntity['type'] { return this.issue.type; } protected set issueTypeValue(value: IssueEntity['type']) { this.issue.type = value; } protected get epicEstimatedTime(): number | null { const times = this.composedIssues .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 epicIssues(): IssueEntity[] { return this.issues().filter((issue) => issue.type === 'Epic'); } protected get composedIssues(): IssueEntity[] { return this.issues().filter( (issue) => issue.id !== this.issue.id && (issue.dependsOnIds.includes(this.issue.id) || issue.epic === this.issue.name), ); } protected get epicCandidates(): IssueEntity[] { const composedIds = new Set(this.composedIssues.map((i) => i.id)); return this.issues().filter( (issue) => issue.id !== this.issue.id && !composedIds.has(issue.id), ); } protected openCreateInEpic(): void { this.newIssueName = ''; this.showCreateInEpic = true; this.showAddToEpic = false; } protected cancelCreateInEpic(): void { this.showCreateInEpic = false; this.newIssueName = ''; } protected async confirmCreateInEpic(): Promise { const name = this.newIssueName.trim(); if (!name) return; const created = await this.issuesStore.upsert({ id: 0, type: 'Story', assignee: '', epic: this.issue.name, name, startDate: '', endDate: '', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'MOYENNE', status: 'draft', progress: 0, }); const epicMilestone = this.currentMilestone; if (epicMilestone) { await this.milestonesStore.upsert({ ...epicMilestone, issueIds: [...epicMilestone.issueIds, created.id], }); } this.showCreateInEpic = false; this.newIssueName = ''; } protected openAddToEpic(): void { this.selectedEpicCandidateId = null; this.showAddToEpic = true; } protected cancelAddToEpic(): void { this.showAddToEpic = false; this.selectedEpicCandidateId = null; } protected async confirmAddToEpic(): Promise { if (this.selectedEpicCandidateId !== null) { const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId); if (target) { await this.issuesStore.upsert({ ...target, epic: this.issue.name }); const epicMilestone = this.currentMilestone; if (epicMilestone) { const prevMilestone = this.milestones().find((m) => m.issueIds.includes(target.id)); if (prevMilestone && prevMilestone.id !== epicMilestone.id) { await this.milestonesStore.upsert({ ...prevMilestone, issueIds: prevMilestone.issueIds.filter((id) => id !== target.id), }); } if (!epicMilestone.issueIds.includes(target.id)) { await this.milestonesStore.upsert({ ...epicMilestone, issueIds: [...epicMilestone.issueIds, target.id], }); } } } } this.showAddToEpic = false; this.selectedEpicCandidateId = null; } protected get isEpicIssue(): boolean { return this.issueTypeValue === 'Epic'; } protected get epicGanttTasks(): GanttTask[] { const tasks: GanttTask[] = []; if (this.issue.startDate && this.issue.endDate) { tasks.push({ id: `issue-${this.issue.id}`, name: this.issue.name || 'Epic', start: this.issue.startDate, end: this.issue.endDate, progress: this.composedIssues.length === 0 ? this.issue.progress : Math.round( (this.composedIssues.filter((i) => i.status === 'done').length / this.composedIssues.length) * 100, ), custom_class: 'bar-epic', }); } for (const child of this.composedIssues) { if (!child.startDate || !child.endDate) continue; tasks.push({ id: `issue-${child.id}`, name: `#${child.id} ${child.name}`, start: child.startDate, end: child.endDate, progress: child.progress, }); } return tasks; } protected get isChildOfEpic(): boolean { return !!this.issue.epic; } protected get dateValidationError(): string | null { const { startDate, endDate } = this.issue; if (startDate && endDate && startDate > endDate) { return 'La date de début ne peut pas être supérieure à la date de fin.'; } if (startDate && this.issue.dependsOnIds.length > 0) { for (const depId of this.issue.dependsOnIds) { const dep = this.issuesStore.getById(depId); if (dep?.endDate && startDate < dep.endDate) { return `La date de début ne peut pas être antérieure à la date de fin de la dépendance #${depId}.`; } } } return null; } protected startEditDescription(): void { this._descriptionBeforeEdit = this.issue.description; this.editingDescription = true; } protected async saveDescription(): Promise { this.editingDescription = false; await this.saveIssue(); } protected cancelEditDescription(): void { this.issue.description = this._descriptionBeforeEdit; this.editingDescription = false; } protected onDescriptionPaste(event: ClipboardEvent): void { const ta = event.target as HTMLTextAreaElement; const start = ta.selectionStart; const end = ta.selectionEnd; handleImagePaste(event, (md) => { this.issue.description = insertAtSelection(ta, this.issue.description, start, end, md); }); } protected get descriptionHtml(): SafeHtml { const html = marked.parse(this.issue.description || '') as string; return this.sanitizer.bypassSecurityTrustHtml(html); } 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 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; label: string } { const map: Record = { 'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545', label: 'Très haute' }, 'HAUTE': { symbol: '↑', color: '#fd7e14', label: 'Haute' }, 'MOYENNE': { symbol: '–', color: '#ffc107', label: 'Moyenne' }, 'BASSE': { symbol: '↓', color: '#0d6efd', label: 'Basse' }, 'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d', label: 'Très faible'}, }; return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority }; } protected getBadgeClass(type: IssueEntity['type']): string { const map: Record = { Bug: 'text-bg-danger', Study: 'text-bg-secondary', Story: 'text-bg-success', Task: 'text-bg-primary', 'Technical Story': 'text-bg-warning', Epic: 'text-bg-info', }; return map[type] ?? 'text-bg-secondary'; } protected openComposedIssue(id: number): void { this.router.navigate(['/issues', id]); } protected get epicIssueId(): number | null { const epic = this.epicIssues.find((e) => e.name === this.issue.epic); return epic?.id ?? null; } protected navigateToEpic(): void { if (this.epicIssueId !== null) { this.router.navigate(['/issues', this.epicIssueId]); } } protected get currentMilestone(): MilestoneEntity | undefined { return this.milestones().find((m) => m.issueIds.includes(this.issue.id)); } protected get currentMilestoneId(): number | null { return this.currentMilestone?.id ?? null; } protected async onMilestoneChange(newMilestoneId: number | null): Promise { if (this.isNewIssueRoute) return; const childIds = this.isEpicIssue ? this.issues().filter((i) => i.epic === this.issue.name).map((i) => i.id) : []; const allIds = [this.issue.id, ...childIds]; const previous = this.currentMilestone; if (previous) { await this.milestonesStore.upsert({ ...previous, issueIds: previous.issueIds.filter((id) => !allIds.includes(id)), }); } if (newMilestoneId !== null) { const target = this.milestones().find((m) => m.id === newMilestoneId); if (target) { const toAdd = allIds.filter((id) => !target.issueIds.includes(id)); await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, ...toAdd] }); } } } protected navigateToMilestone(): void { if (this.currentMilestoneId !== null) { this.router.navigate(['/milestones', this.currentMilestoneId]); } } protected async saveIssue(explicit = false): Promise { if (this.isNewIssueRoute && !explicit) return; if (!this.issue.name.trim()) return; if (this.dateValidationError) return; const saved = await this.issuesStore.upsert(this.issue); this.issue = { ...saved }; if (this.isNewIssueRoute) { this.router.navigate(['/issues', saved.id]); } } protected cancelCreation(): void { this.router.navigate(['/issues']); } protected async deleteIssue(): Promise { await this.issuesStore.deleteById(this.issue.id); this.router.navigate(['/issues']); } protected async updateStatus(status: IssueEntity['status']): Promise { this.issue.status = status; const saved = await this.issuesStore.upsert(this.issue); this.issue = { ...saved }; } protected toggleMoreMenu(): void { this.moreMenuOpen = !this.moreMenuOpen; } protected closeMoreMenu(): void { this.moreMenuOpen = false; } protected toggleStatusMenu(): void { this.statusMenuOpen = !this.statusMenuOpen; } protected closeStatusMenu(): void { this.statusMenuOpen = false; } protected async selectStatus(status: IssueEntity['status']): Promise { this.statusMenuOpen = false; await this.updateStatus(status); } private buildIssue(): IssueEntity { const idParam = this.route.snapshot.paramMap.get('id'); const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; if (isNewIssueRoute) { const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0); return { id: draftId, type: 'Story', assignee: '', epic: '', name: '', startDate: '', endDate: '', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'MOYENNE', status: 'draft', progress: 0, }; } const safeId = Number(idParam ?? 0); const existingIssue = this.issuesStore.getById(safeId); return ( existingIssue ?? { id: safeId, type: 'Story', assignee: '', epic: '', name: '', startDate: '', endDate: '', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'MOYENNE', status: 'draft', progress: 0, } ); } }