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
@@ -47,6 +47,26 @@
<div class="card-header section-header">Informations</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="field-label">Date de début</label>
<input
aria-label="Date de début"
class="form-control form-control-sm"
type="date"
[(ngModel)]="milestone.startDate"
(blur)="saveMilestone()"
/>
</div>
<div class="col-md-4">
<label class="field-label">Date de fin</label>
<input
aria-label="Date de fin"
class="form-control form-control-sm"
type="date"
[(ngModel)]="milestone.endDate"
(blur)="saveMilestone()"
/>
</div>
<div class="col-md-4">
<label class="field-label">Date d'échéance</label>
<input
@@ -237,6 +257,16 @@
</div>
</div>
<!-- Diagramme Gantt -->
@if (!isNewRoute) {
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Diagramme Gantt</div>
<div class="card-body">
<app-gantt-diagram [tasks]="milestoneGanttTasks" />
</div>
</div>
}
<!-- Boutons de création -->
@if (isNewRoute) {
<div class="d-flex gap-2">
@@ -31,6 +31,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): 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([
@@ -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: [] };
}
}
@@ -10,6 +10,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): 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));
+25 -2
View File
@@ -4,9 +4,16 @@
<h1 class="h2 mb-2">Milestones</h1>
<p class="text-secondary mb-0">Objectifs et jalons du projet.</p>
</div>
<button type="button" class="btn btn-primary" (click)="createMilestone()">Créer</button>
<div class="d-flex gap-2">
<div class="btn-group">
<button type="button" class="btn btn-sm" [class.btn-outline-secondary]="viewMode !== 'list'" [class.btn-secondary]="viewMode === 'list'" (click)="viewMode = 'list'" title="Vue liste"></button>
<button type="button" class="btn btn-sm" [class.btn-outline-secondary]="viewMode !== 'gantt'" [class.btn-secondary]="viewMode === 'gantt'" (click)="viewMode = 'gantt'" title="Vue Gantt"></button>
</div>
<button type="button" class="btn btn-primary" (click)="createMilestone()">Créer</button>
</div>
</div>
@if (viewMode === 'list') {
<div class="mb-3">
<input
type="search"
@@ -16,7 +23,18 @@
style="max-width: 300px;"
/>
</div>
}
@if (viewMode === 'gantt') {
<div class="card shadow-sm">
<div class="card-header section-header">Diagramme Gantt</div>
<div class="card-body">
<app-gantt-diagram [tasks]="ganttTasks" />
</div>
</div>
}
@if (viewMode === 'list') {
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
@@ -25,6 +43,8 @@
<th>#</th>
<th>Nom</th>
<th>Description</th>
<th>Début</th>
<th>Fin</th>
<th>Échéance</th>
<th>Issues</th>
<th>Progression</th>
@@ -41,6 +61,8 @@
<td class="text-secondary small">#{{ milestone.id }}</td>
<td class="fw-semibold">{{ milestone.name }}</td>
<td class="text-secondary small description-cell">{{ milestone.description }}</td>
<td class="text-nowrap small">{{ formatDate(milestone.startDate) }}</td>
<td class="text-nowrap small">{{ formatDate(milestone.endDate) }}</td>
<td class="text-nowrap small">{{ formatDate(milestone.dueDate) }}</td>
<td>
<span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span>
@@ -64,10 +86,11 @@
}
@if (filteredMilestones.length === 0) {
<tr>
<td colspan="6" class="text-center text-secondary py-4">Aucun milestone trouvé.</td>
<td colspan="8" class="text-center text-secondary py-4">Aucun milestone trouvé.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@@ -9,6 +9,8 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
id: 1,
name: 'Sprint 1',
description: '',
startDate: '',
endDate: '',
dueDate: '',
issueIds: [],
...overrides,
+4
View File
@@ -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')
+35 -1
View File
@@ -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();