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) {
+
+}
+
@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 @@
+
+
+
+
+
+
+
+
+
+@if (!isNewRoute) {
+
+}
+
@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') {
+
+}
+
+@if (viewMode === 'list') {
@@ -25,6 +43,8 @@
| # |
Nom |
Description |
+ Début |
+ Fin |
Échéance |
Issues |
Progression |
@@ -41,6 +61,8 @@
#{{ 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;
+ }
+}