321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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: [] };
|
||
}
|
||
}
|