Ajout diagram de gantt

This commit is contained in:
2026-05-30 06:06:57 +02:00
parent ba6a3d0827
commit b3bc0f9336
22 changed files with 684 additions and 14 deletions
@@ -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;
}
@@ -0,0 +1,25 @@
@if (hasTasks) {
<div class="gantt-toolbar">
@for (mode of viewModes; track mode) {
<button
type="button"
class="btn btn-sm"
[class.btn-primary]="activeViewMode === mode"
[class.btn-outline-secondary]="activeViewMode !== mode"
(click)="setViewMode(mode)"
>{{ viewModeLabels[mode] }}</button>
}
<button
type="button"
class="btn btn-sm btn-outline-secondary"
(click)="scrollToToday()"
>Aujourd'hui</button>
</div>
<div class="gantt-wrapper">
<div #ganttContainer class="gantt-container"></div>
</div>
} @else {
<p class="text-secondary small mb-0">
Aucune tâche avec des dates de début et de fin définies.
</p>
}
@@ -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> = {}): 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<GanttDiagram>;
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<HTMLButtonElement> =
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<HTMLButtonElement> =
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<HTMLButtonElement> =
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<HTMLButtonElement> =
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);
});
});
});
@@ -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<HTMLElement>;
@Input() tasks: GanttTask[] = [];
protected readonly viewModes: ViewMode[] = ['Day', 'Week', 'Month'];
protected readonly viewModeLabels: Record<ViewMode, string> = {
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,
},
);
});
});
}
}