531 lines
17 KiB
TypeScript
531 lines
17 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.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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<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 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<IssueEntity['priority'], { symbol: string; color: string; label: string }> = {
|
||
'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<IssueEntity['type'], string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await this.issuesStore.deleteById(this.issue.id);
|
||
this.router.navigate(['/issues']);
|
||
}
|
||
|
||
protected async updateStatus(status: IssueEntity['status']): Promise<void> {
|
||
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<void> {
|
||
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,
|
||
}
|
||
);
|
||
}
|
||
}
|