diff --git a/.claude/settings.json b/.claude/settings.json index 81c0137..0f0d188 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,13 @@ "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)", "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('projects',{}\\).get\\('Bonsai-webapp',{}\\).get\\('architect',{}\\).get\\('test',{}\\), indent=2\\)\\)\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")" + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d['projects']['bonsai-webapp']['architect']['build']['options'], indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); keys=list\\(d['projects'].keys\\(\\)\\); print\\(keys\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); opts=d['projects']['Bonsai-webapp']['architect']['build']['options']; print\\(json.dumps\\({k:opts[k] for k in ['styles','scripts','assets'] if k in opts}, indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('main',''\\), d.get\\('module',''\\), d.get\\('types',''\\), d.get\\('exports',''\\)\\)\")", + "Bash(grep -n \"^\\\\s*function \\\\$\\\\|const \\\\$ =\\\\|\\\\$ = \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)", + "Bash(grep -n \"function \\\\$\\\\b\\\\|const \\\\$ \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)" ], "additionalDirectories": [ "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", diff --git a/angular.json b/angular.json index f5974fd..431e4d9 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,8 @@ } ], "styles": [ - "src/styles.css" + "src/styles.css", + "node_modules/frappe-gantt/dist/frappe-gantt.css" ] }, "configurations": { diff --git a/package-lock.json b/package-lock.json index ad85746..656e5ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bonsai-webapp", - "version": "0.1.0", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bonsai-webapp", - "version": "0.1.0", + "version": "0.1.11", "dependencies": { "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -15,6 +15,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "frappe-gantt": "^1.2.2", "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", @@ -5387,6 +5388,12 @@ "node": ">= 0.6" } }, + "node_modules/frappe-gantt": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.2.2.tgz", + "integrity": "sha512-1+uPNRa92LBIeKiZCVhJMiGIifJk5ONaoerXI8eBREXWRZGGoTJR5ATpMpsnAQcyBA6Gnq5wIht4eL+e4eHMBA==", + "license": "MIT" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", diff --git a/package.json b/package.json index 870d4d6..dc6f8de 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "frappe-gantt": "^1.2.2", "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", diff --git a/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts index 2bcf53d..01a0e00 100644 --- a/src/app/dashboard/dashboard.spec.ts +++ b/src/app/dashboard/dashboard.spec.ts @@ -238,7 +238,7 @@ describe('Dashboard', () => { describe('activeMilestones', () => { it('exclut les milestones terminés à 100%', () => { issuesStore.seed([makeIssue({ id: 1, status: 'done' })]); - milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', dueDate: '', issueIds: [1] }]); + milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1] }]); expect((component as any).activeMilestones().length).toBe(0); }); @@ -247,7 +247,7 @@ describe('Dashboard', () => { makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 2, status: 'todo' }), ]); - milestonesStore.seed([{ id: 1, name: 'Active', description: '', dueDate: '', issueIds: [1, 2] }]); + milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2] }]); expect((component as any).activeMilestones().length).toBe(1); }); }); diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index c44f96c..3b10b40 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -341,6 +341,16 @@ } + +@if (isEpicIssue && !isNewIssueRoute) { +
+
Diagramme Gantt
+
+ +
+
+} + @if (!isNewIssueRoute) { diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 0f21a2f..67e1ef1 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -88,6 +88,8 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], ...overrides, diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index ac1e094..8626816 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -9,10 +9,11 @@ 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], + imports: [FormsModule, IssueComments, GanttDiagram], templateUrl: './issue-detail.html', styleUrl: './issue-detail.css', }) @@ -233,6 +234,39 @@ export class IssueDetail { 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; } diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index a0b75d9..944832c 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -95,6 +95,8 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], ...overrides, diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html index 901ac47..9e7169b 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.html +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -47,6 +47,26 @@
Informations
+
+ + +
+
+ + +
+ +@if (!isNewRoute) { +
+
Diagramme Gantt
+
+ +
+
+} + @if (isNewRoute) {
diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index e67a699..670e7b5 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -31,6 +31,8 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], ...overrides, @@ -275,6 +277,46 @@ describe('MilestoneDetail', () => { }); }); + describe('milestoneGanttTasks', () => { + it('returns empty array when no linked issues', () => { + issuesStore.seed([]); + (component as any).milestone.issueIds = []; + expect((component as any).milestoneGanttTasks).toHaveLength(0); + }); + + it('excludes issues missing startDate or endDate', () => { + issuesStore.seed([ + makeIssue({ id: 1, startDate: '2025-01-01', endDate: '' }), + makeIssue({ id: 2, startDate: '', endDate: '2025-01-31' }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).milestoneGanttTasks).toHaveLength(0); + }); + + it('returns a task for each issue with both dates', () => { + issuesStore.seed([ + makeIssue({ id: 1, name: 'Task A', startDate: '2025-01-01', endDate: '2025-01-15', progress: 50 }), + makeIssue({ id: 2, name: 'Task B', startDate: '2025-01-10', endDate: '2025-01-31', progress: 0 }), + ]); + (component as any).milestone.issueIds = [1, 2]; + const tasks = (component as any).milestoneGanttTasks; + expect(tasks).toHaveLength(2); + expect(tasks[0]).toMatchObject({ id: 'issue-1', name: '#1 Task A', start: '2025-01-01', end: '2025-01-15', progress: 50 }); + expect(tasks[1]).toMatchObject({ id: 'issue-2', name: '#2 Task B', start: '2025-01-10', end: '2025-01-31', progress: 0 }); + }); + + it('only includes issues linked to the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, startDate: '2025-01-01', endDate: '2025-01-31' }), + makeIssue({ id: 2, startDate: '2025-02-01', endDate: '2025-02-28' }), + ]); + (component as any).milestone.issueIds = [1]; + const tasks = (component as any).milestoneGanttTasks; + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('issue-1'); + }); + }); + describe('issueSuggestions', () => { beforeEach(() => { issuesStore.seed([ diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index ba33c26..e6b8131 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -8,10 +8,11 @@ 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], + imports: [FormsModule, GanttDiagram], templateUrl: './milestone-detail.html', styleUrl: './milestone-detail.css', }) @@ -94,6 +95,21 @@ export class MilestoneDetail { 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 progress(): number { if (this.linkedIssues.length === 0) return 0; return Math.round( @@ -245,9 +261,9 @@ export class MilestoneDetail { private buildMilestone(): MilestoneEntity { if (this.route.snapshot.routeConfig?.path === 'milestones/new') { - return { id: 0, name: '', description: '', dueDate: '', issueIds: [] }; + return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; } const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); - return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] }; + return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; } } diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts index 7270385..fcac7e5 100644 --- a/src/app/milestones/milestones-api.service.spec.ts +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -10,6 +10,8 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], ...overrides, @@ -43,7 +45,7 @@ describe('MilestonesApiService', () => { describe('create', () => { it('sends POST /api/milestones with the body and returns the created milestone', () => { - const body = { name: 'Sprint 2', description: '', dueDate: '', issueIds: [] }; + const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; const response = makeMilestone({ id: 2, name: 'Sprint 2' }); let result: MilestoneEntity | undefined; service.create(body).subscribe((data) => (result = data)); diff --git a/src/app/milestones/milestones.html b/src/app/milestones/milestones.html index 86ea69d..9a5c3f5 100644 --- a/src/app/milestones/milestones.html +++ b/src/app/milestones/milestones.html @@ -4,9 +4,16 @@

Milestones

Objectifs et jalons du projet.

- +
+
+ + +
+ +
+@if (viewMode === 'list') {
+} +@if (viewMode === 'gantt') { +
+
Diagramme Gantt
+
+ +
+
+} + +@if (viewMode === 'list') {
@@ -25,6 +43,8 @@ + + @@ -41,6 +61,8 @@ + + - + }
# Nom DescriptionDébutFin Échéance Issues Progression#{{ milestone.id }} {{ milestone.name }} {{ milestone.description }}{{ formatDate(milestone.startDate) }}{{ formatDate(milestone.endDate) }} {{ formatDate(milestone.dueDate) }} {{ milestone.issueIds.length }} @@ -64,10 +86,11 @@ } @if (filteredMilestones.length === 0) {
Aucun milestone trouvé.Aucun milestone trouvé.
+} diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts index 6ae878c..b54499f 100644 --- a/src/app/milestones/milestones.store.spec.ts +++ b/src/app/milestones/milestones.store.spec.ts @@ -9,6 +9,8 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], ...overrides, diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts index d844c89..47393e5 100644 --- a/src/app/milestones/milestones.store.ts +++ b/src/app/milestones/milestones.store.ts @@ -6,6 +6,8 @@ export type MilestoneEntity = { id: number; name: string; description: string; + startDate: string; + endDate: string; dueDate: string; issueIds: number[]; }; @@ -67,6 +69,8 @@ export class MilestonesStore { id: milestone.id ?? 0, name: milestone.name ?? '', description: milestone.description ?? '', + startDate: milestone.startDate ?? '', + endDate: milestone.endDate ?? '', dueDate: milestone.dueDate ?? '', issueIds: Array.isArray(milestone.issueIds) ? milestone.issueIds.filter((v): v is number => typeof v === 'number') diff --git a/src/app/milestones/milestones.ts b/src/app/milestones/milestones.ts index 8ad6bb5..18f3d8f 100644 --- a/src/app/milestones/milestones.ts +++ b/src/app/milestones/milestones.ts @@ -3,10 +3,11 @@ import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from './milestones.store'; +import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram'; @Component({ selector: 'app-milestones', - imports: [FormsModule], + imports: [FormsModule, GanttDiagram], templateUrl: './milestones.html', styleUrl: './milestones.css', }) @@ -22,6 +23,39 @@ export class Milestones { protected readonly milestones = this.milestonesStore.milestones; protected searchQuery = ''; + protected viewMode: 'list' | 'gantt' = 'list'; + + protected get ganttTasks(): GanttTask[] { + const today = new Date().toISOString().slice(0, 10); + const tasks: GanttTask[] = []; + + for (const milestone of this.milestones()) { + const end = milestone.endDate || milestone.dueDate; + if (!end) continue; + + let start = milestone.startDate; + if (!start) { + const linkedIssues = this.issuesStore.issues().filter((i) => + milestone.issueIds.includes(i.id), + ); + const starts = linkedIssues.map((i) => i.startDate).filter(Boolean); + start = starts.length > 0 + ? starts.reduce((min, d) => (d < min ? d : min)) + : today; + } + const clampedEnd = end < start ? start : end; + + tasks.push({ + id: `milestone-${milestone.id}`, + name: milestone.name, + start, + end: clampedEnd, + progress: this.getProgress(milestone), + }); + } + + return tasks; + } protected get filteredMilestones(): MilestoneEntity[] { const q = this.searchQuery.trim().toLowerCase(); diff --git a/src/app/shared/gantt-diagram/gantt-diagram.css b/src/app/shared/gantt-diagram/gantt-diagram.css new file mode 100644 index 0000000..ae452d2 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.css @@ -0,0 +1,27 @@ +:host { + display: block; + --g-bar-color: #f0fdf4; + --g-bar-border: #9ae6b4; + --g-progress-color: #38a169; + --g-expected-progress: #2f855a; + --g-handle-color: #744210; + --g-today-highlight: #276749; +} + +.gantt-toolbar { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.gantt-wrapper { + overflow-x: auto; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.gantt-container { + min-width: 600px; + padding: 0.5rem 0; +} diff --git a/src/app/shared/gantt-diagram/gantt-diagram.html b/src/app/shared/gantt-diagram/gantt-diagram.html new file mode 100644 index 0000000..801f1a9 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.html @@ -0,0 +1,25 @@ +@if (hasTasks) { +
+ @for (mode of viewModes; track mode) { + + } + +
+
+
+
+} @else { +

+ Aucune tâche avec des dates de début et de fin définies. +

+} diff --git a/src/app/shared/gantt-diagram/gantt-diagram.spec.ts b/src/app/shared/gantt-diagram/gantt-diagram.spec.ts new file mode 100644 index 0000000..b4a747b --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.spec.ts @@ -0,0 +1,254 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GanttDiagram, GanttTask } from './gantt-diagram'; +import { vi, afterEach } from 'vitest'; + +vi.mock('frappe-gantt', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: vi.fn().mockImplementation(class {} as any), +})); + +const makeTask = (overrides: Partial = {}): GanttTask => ({ + id: 'task-1', + name: 'Tâche test', + start: '2026-06-01', + end: '2026-06-15', + progress: 50, + ...overrides, +}); + +const makeFakeGantt = () => ({ + refresh: vi.fn(), + change_view_mode: vi.fn(), +}); + +const makeFakeGanttWithScroll = (today = new Date()) => { + const ganttStart = new Date(today.getFullYear(), today.getMonth() - 2, 1); + const ganttEnd = new Date(today.getFullYear(), today.getMonth() + 4, 1); + return { + refresh: vi.fn(), + change_view_mode: vi.fn(), + scroll_current: vi.fn(), + gantt_start: ganttStart, + gantt_end: ganttEnd, + config: { column_width: 120 }, + $container: { scrollTo: vi.fn() }, + }; +}; + +describe('GanttDiagram', () => { + let fixture: ComponentFixture; + let component: GanttDiagram; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GanttDiagram], + }).compileComponents(); + + fixture = TestBed.createComponent(GanttDiagram); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('état initial', () => { + it('affiche le message vide quand aucune tâche', () => { + component.tasks = []; + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Aucune tâche avec des dates'); + }); + + it('ne rend pas le conteneur gantt quand aucune tâche', () => { + component.tasks = []; + fixture.detectChanges(); + const container = fixture.nativeElement.querySelector('.gantt-container'); + expect(container).toBeNull(); + }); + }); + + describe('avec des tâches', () => { + it('affiche le conteneur gantt quand des tâches sont fournies', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const container = fixture.nativeElement.querySelector('.gantt-container'); + expect(container).not.toBeNull(); + }); + + it('affiche les boutons de mode de vue et le bouton Aujourd\'hui', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + expect(buttons.length).toBe(4); + const labels = Array.from(buttons).map((b) => b.textContent?.trim()); + expect(labels).toContain('Jour'); + expect(labels).toContain('Semaine'); + expect(labels).toContain('Mois'); + expect(labels).toContain("Aujourd'hui"); + }); + + it('active le mode Semaine par défaut', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const active = Array.from(buttons).find((b) => + b.classList.contains('btn-primary'), + ); + expect(active?.textContent?.trim()).toBe('Semaine'); + }); + }); + + describe('changement de mode de vue', () => { + it('met à jour activeViewMode au clic sur Mois', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const monthBtn = Array.from(buttons).find( + (b) => b.textContent?.trim() === 'Mois', + ); + monthBtn?.click(); + fixture.detectChanges(); + expect(component['activeViewMode']).toBe('Month'); + }); + + it('le bouton actif change après le clic sur Jour', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const dayBtn = Array.from(buttons).find( + (b) => b.textContent?.trim() === 'Jour', + ); + dayBtn?.click(); + fixture.detectChanges(); + expect(dayBtn?.classList.contains('btn-primary')).toBe(true); + }); + + it("appelle change_view_mode sur le gantt si initialisé", () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + component['setViewMode']('Month'); + expect(fakeGantt.change_view_mode).toHaveBeenCalledWith('Month'); + }); + + it("ne plante pas si le gantt n'est pas encore initialisé", () => { + expect(() => component['setViewMode']('Month')).not.toThrow(); + }); + }); + + describe('ngOnChanges', () => { + it('rafraîchit le diagramme quand les tâches changent et le gantt est initialisé', () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + (component as any)['initialized'] = true; + const newTasks = [makeTask({ id: 'task-2', name: 'Nouvelle tâche' })]; + component.tasks = newTasks; + component.ngOnChanges({ + tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + expect(fakeGantt.refresh).toHaveBeenCalledWith(newTasks); + }); + + it("ne lève pas d'erreur si appelé avant AfterViewInit", () => { + expect(() => { + component.ngOnChanges({ + tasks: { currentValue: [makeTask()], previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + }).not.toThrow(); + }); + + it('appelle renderGantt si le gantt est null au changement de tâches', () => { + fixture.detectChanges(); // init sans tâches : AfterViewInit ne rend rien + const renderSpy = vi.spyOn(component as any, 'renderGantt'); + (component as any)['gantt'] = null; + const newTasks = [makeTask()]; + component.tasks = newTasks; + component.ngOnChanges({ + tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + expect(renderSpy).toHaveBeenCalled(); + }); + }); + + describe('hasTasks', () => { + it('retourne false quand tasks est vide', () => { + component.tasks = []; + expect(component.hasTasks).toBe(false); + }); + + it('retourne true quand des tâches existent', () => { + component.tasks = [makeTask()]; + expect(component.hasTasks).toBe(true); + }); + }); + + describe('scrollToToday', () => { + it('ne fait rien si le gantt n\'est pas initialisé', () => { + expect(() => component['scrollToToday']()).not.toThrow(); + }); + + it('ne fait rien si aujourd\'hui est hors de la plage du gantt', () => { + const fakeGantt = makeFakeGanttWithScroll(); + fakeGantt.gantt_start = new Date(2030, 0, 1); + fakeGantt.gantt_end = new Date(2030, 11, 31); + (component as any)['gantt'] = fakeGantt; + component['scrollToToday'](); + expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled(); + expect(fakeGantt.scroll_current).not.toHaveBeenCalled(); + }); + + it('appelle scrollTo avec la bonne position en vue Mois', () => { + const today = new Date(); + const fakeGantt = makeFakeGanttWithScroll(today); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Month'; + component['scrollToToday'](); + const monthDiff = + (today.getFullYear() - fakeGantt.gantt_start.getFullYear()) * 12 + + today.getMonth() - fakeGantt.gantt_start.getMonth(); + const expectedLeft = Math.max(0, monthDiff * 120 - 120 / 6); + expect(fakeGantt.$container.scrollTo).toHaveBeenCalledWith({ + left: expectedLeft, + behavior: 'smooth', + }); + }); + + it('appelle scroll_current en vue Semaine', () => { + const fakeGantt = makeFakeGanttWithScroll(); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Week'; + component['scrollToToday'](); + expect(fakeGantt.scroll_current).toHaveBeenCalled(); + expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled(); + }); + + it('appelle scroll_current en vue Jour', () => { + const fakeGantt = makeFakeGanttWithScroll(); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Day'; + component['scrollToToday'](); + expect(fakeGantt.scroll_current).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy', () => { + it('met gantt à null à la destruction', () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + component.ngOnDestroy(); + expect(component['gantt']).toBeNull(); + }); + + it('marque le composant comme détruit', () => { + component.ngOnDestroy(); + expect(component['destroyed']).toBe(true); + }); + }); +}); diff --git a/src/app/shared/gantt-diagram/gantt-diagram.ts b/src/app/shared/gantt-diagram/gantt-diagram.ts new file mode 100644 index 0000000..7a63262 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.ts @@ -0,0 +1,120 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + NgZone, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import type Gantt from 'frappe-gantt'; +import type { GanttTask } from 'frappe-gantt'; + +export type { GanttTask }; + +type ViewMode = 'Day' | 'Week' | 'Month'; + +@Component({ + selector: 'app-gantt-diagram', + templateUrl: './gantt-diagram.html', + styleUrl: './gantt-diagram.css', +}) +export class GanttDiagram implements AfterViewInit, OnChanges, OnDestroy { + @ViewChild('ganttContainer') ganttContainer!: ElementRef; + @Input() tasks: GanttTask[] = []; + + protected readonly viewModes: ViewMode[] = ['Day', 'Week', 'Month']; + protected readonly viewModeLabels: Record = { + Day: 'Jour', + Week: 'Semaine', + Month: 'Mois', + }; + protected activeViewMode: ViewMode = 'Week'; + + private gantt: Gantt | null = null; + private initialized = false; + private destroyed = false; + + constructor(private readonly zone: NgZone) {} + + ngAfterViewInit(): void { + this.initialized = true; + this.renderGantt(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.initialized) return; + if (changes['tasks']) { + if (this.gantt) { + this.zone.runOutsideAngular(() => this.gantt!.refresh(this.tasks)); + } else { + this.renderGantt(); + } + } + } + + ngOnDestroy(): void { + this.destroyed = true; + this.gantt = null; + } + + protected setViewMode(mode: ViewMode): void { + this.activeViewMode = mode; + if (this.gantt) { + this.zone.runOutsideAngular(() => this.gantt!.change_view_mode(mode)); + } + } + + protected scrollToToday(): void { + if (!this.gantt) return; + this.zone.runOutsideAngular(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = this.gantt as any; + const today = new Date(); + if (today < g.gantt_start || today > g.gantt_end) return; + + if (this.activeViewMode === 'Month') { + // Bug frappe-gantt : get_closest_date() construit new Date("YYYY-MM ") + // qui est un Invalid Date → position NaN → aucun scroll. + // Correctif : calcul entier du décalage en mois. + const monthDiff = + (today.getFullYear() - g.gantt_start.getFullYear()) * 12 + + today.getMonth() - g.gantt_start.getMonth(); + const left = Math.max( + 0, + monthDiff * g.config.column_width - g.config.column_width / 6, + ); + g.$container.scrollTo({ left, behavior: 'smooth' }); + } else { + g.scroll_current(); + } + }); + } + + get hasTasks(): boolean { + return this.tasks.length > 0; + } + + private renderGantt(): void { + if (!this.tasks.length || !this.ganttContainer) return; + + import('frappe-gantt').then(({ default: GanttClass }) => { + if (this.destroyed || !this.ganttContainer) return; + this.zone.runOutsideAngular(() => { + this.ganttContainer.nativeElement.innerHTML = ''; + this.gantt = new GanttClass( + this.ganttContainer.nativeElement, + this.tasks, + { + view_mode: this.activeViewMode, + language: 'fr', + popup: false, + today_button: false, + }, + ); + }); + }); + } +} diff --git a/src/frappe-gantt.d.ts b/src/frappe-gantt.d.ts new file mode 100644 index 0000000..9e8bfa3 --- /dev/null +++ b/src/frappe-gantt.d.ts @@ -0,0 +1,28 @@ +declare module 'frappe-gantt' { + export interface GanttTask { + id: string; + name: string; + start: string; + end: string; + progress: number; + dependencies?: string; + custom_class?: string; + } + + export interface GanttOptions { + view_mode?: string; + language?: string; + popup?: boolean | ((task: GanttTask, x: number, y: number) => string); + today_button?: boolean; + on_click?: (task: GanttTask) => void; + on_date_change?: (task: GanttTask, start: Date, end: Date) => void; + on_progress_change?: (task: GanttTask, progress: number) => void; + on_view_change?: (mode: string) => void; + } + + export default class Gantt { + constructor(element: string | HTMLElement, tasks: GanttTask[], options?: GanttOptions); + refresh(tasks: GanttTask[]): void; + change_view_mode(mode?: string): void; + } +}