diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 71ef07f..f9c1def 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -2,6 +2,8 @@ import { Routes } from '@angular/router';
import { Home } from './home/home';
import { IssueDetail } from './issues/issue-detail/issue-detail';
import { Issues } from './issues/issues';
+import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
+import { Milestones } from './milestones/milestones';
import { Projects } from './projects/projects';
import { authGuard } from './auth/auth.guard';
@@ -13,5 +15,8 @@ export const routes: Routes = [
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
{ path: 'issues', component: Issues, canActivate: [authGuard] },
+ { path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
+ { path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
+ { path: 'milestones', component: Milestones, canActivate: [authGuard] },
{ path: '**', redirectTo: 'home' },
];
diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts
index f8ec9c6..0d47234 100644
--- a/src/app/menu/menu.spec.ts
+++ b/src/app/menu/menu.spec.ts
@@ -21,13 +21,18 @@ describe('Menu', () => {
expect(component).toBeTruthy();
});
- it('should have three menu items', () => {
+ it('should have four menu items', () => {
const items = (component as any).menuItems as { label: string; path: string }[];
- expect(items.length).toBe(3);
+ expect(items.length).toBe(4);
});
it('should contain Issues link', () => {
const items = (component as any).menuItems as { label: string; path: string }[];
expect(items.some((i) => i.path === '/issues')).toBe(true);
});
+
+ it('should contain Milestones link', () => {
+ const items = (component as any).menuItems as { label: string; path: string }[];
+ expect(items.some((i) => i.path === '/milestones')).toBe(true);
+ });
});
diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts
index ea4735c..bf54844 100644
--- a/src/app/menu/menu.ts
+++ b/src/app/menu/menu.ts
@@ -17,6 +17,7 @@ export class Menu {
{ label: 'Accueil', path: '/home' },
{ label: 'Projet', path: '/project' },
{ label: 'Issues', path: '/issues' },
+ { label: 'Milestones', path: '/milestones' },
];
protected logout(): void {
diff --git a/src/app/milestones/milestone-detail/milestone-detail.css b/src/app/milestones/milestone-detail/milestone-detail.css
new file mode 100644
index 0000000..64f5182
--- /dev/null
+++ b/src/app/milestones/milestone-detail/milestone-detail.css
@@ -0,0 +1,203 @@
+:host {
+ display: block;
+}
+
+.section-header {
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: #6b7280;
+ background-color: #f9fafb;
+}
+
+.field-label {
+ display: block;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: #374151;
+ margin-bottom: 0.3rem;
+}
+
+.title-input::placeholder {
+ color: #9ca3af;
+ font-weight: 400;
+}
+
+/* More menu */
+.more-wrapper {
+ position: relative;
+}
+
+.more-menu {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 0.35rem);
+ min-width: 10rem;
+ z-index: 10;
+}
+
+.more-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 9;
+}
+
+/* Issue name link in table */
+.issue-name-btn {
+ border: none;
+ background: transparent;
+ padding: 0;
+ cursor: pointer;
+ color: #374151;
+ font-size: 0.9rem;
+ text-align: left;
+}
+
+.issue-name-btn:hover {
+ color: #2563eb;
+ text-decoration: underline;
+}
+
+/* Issue search */
+.issue-search-wrapper {
+ position: relative;
+ max-width: 380px;
+}
+
+.issue-suggestions {
+ position: absolute;
+ top: calc(100% + 2px);
+ left: 0;
+ right: 0;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ list-style: none;
+ margin: 0;
+ padding: 0.25rem 0;
+ z-index: 20;
+}
+
+.issue-suggestion-item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0.45rem 0.75rem;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 0.875rem;
+ text-align: left;
+ color: #374151;
+ gap: 0.25rem;
+}
+
+.issue-suggestion-item:hover {
+ background: #f3f4f6;
+}
+
+.dep-remove {
+ border: none;
+ background: transparent;
+ color: #9ca3af;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ border-radius: 50%;
+ width: 1.1rem;
+ height: 1.1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dep-remove:hover {
+ color: #b91c1c;
+ background: #fee2e2;
+}
+
+.dep-select {
+ flex: 1;
+ min-width: 200px;
+}
+
+/* Description */
+.description-textarea {
+ min-height: 12rem;
+ resize: vertical;
+}
+
+.description-preview {
+ min-height: 5rem;
+ white-space: pre-wrap;
+ font-size: 0.9rem;
+ color: #374151;
+ cursor: text;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.35rem;
+ line-height: 1.6;
+}
+
+.description-preview:hover {
+ background: #f9fafb;
+}
+
+.description-placeholder {
+ color: #9ca3af;
+}
+
+.markdown-body {
+ font-size: 0.9rem;
+ line-height: 1.7;
+ color: #374151;
+}
+
+.markdown-body :is(h1, h2, h3, h4, h5, h6) {
+ margin-top: 1rem;
+ margin-bottom: 0.4rem;
+ font-weight: 700;
+}
+
+.markdown-body h1 { font-size: 1.4rem; }
+.markdown-body h2 { font-size: 1.2rem; }
+.markdown-body h3 { font-size: 1rem; }
+
+.markdown-body p { margin-bottom: 0.6rem; }
+
+.markdown-body ul,
+.markdown-body ol {
+ padding-left: 1.4rem;
+ margin-bottom: 0.6rem;
+}
+
+.markdown-body code {
+ background: #f3f4f6;
+ border-radius: 0.25rem;
+ padding: 0.1em 0.35em;
+ font-size: 0.85em;
+}
+
+.markdown-body pre {
+ background: #f3f4f6;
+ border-radius: 0.4rem;
+ padding: 0.75rem 1rem;
+ overflow-x: auto;
+ margin-bottom: 0.6rem;
+}
+
+.markdown-body pre code {
+ background: none;
+ padding: 0;
+}
+
+.markdown-body img {
+ max-width: 100%;
+ border-radius: 0.375rem;
+}
+
+.markdown-body > *:last-child {
+ margin-bottom: 0;
+}
diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html
new file mode 100644
index 0000000..e2a6ec7
--- /dev/null
+++ b/src/app/milestones/milestone-detail/milestone-detail.html
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+ @if (!isNewRoute) {
+
+
+ @if (moreMenuOpen) {
+
+ }
+ @if (moreMenuOpen) {
+
+ }
+
+ }
+
+
+
+
+
+
+
+ @if (nameInput.invalid && nameInput.touched) {
+
Le nom est obligatoire.
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (!isNewRoute) {
+
+ }
+
+
+
+
+
+
+
+
+ @if (editingDescription) {
+
+ } @else {
+
+ @if (milestone.description) {
+
+ } @else {
+
Ajouter une description...
+ }
+
+ }
+
+
+
+
+
+
+
+ @if (linkedIssues.length > 0) {
+
+
+
+
+ | # |
+ Type |
+ Titre |
+ Priorité |
+ Statut |
+ |
+
+
+
+ @for (issue of linkedIssues; track issue.id) {
+
+ | #{{ issue.id }} |
+
+
+ {{ typeIcon(issue.type).letter }}
+
+ |
+
+
+ |
+
+ {{ priorityDisplay(issue.priority).symbol }}
+ |
+
+ {{ statusBadge(issue.status).label }}
+ |
+
+
+ |
+
+ }
+
+
+
+ }
+
+
0">
+ @if (showAddIssue) {
+
+
+
+
+
+ @if (showIssueSuggestions && issueSuggestions.length > 0) {
+
+ @for (issue of issueSuggestions; track issue.id) {
+ -
+
+
+ }
+
+ }
+ @if (showIssueSuggestions && issueSearchQuery && issueSuggestions.length === 0) {
+
Aucune issue trouvée.
+ }
+
+ } @else {
+
+ }
+
+
+
+
+@if (isNewRoute) {
+
+
+
+
+}
diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts
new file mode 100644
index 0000000..b507f7f
--- /dev/null
+++ b/src/app/milestones/milestone-detail/milestone-detail.ts
@@ -0,0 +1,200 @@
+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/issues.store';
+import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
+import { MilestoneEntity, MilestonesStore } from '../milestones.store';
+
+@Component({
+ selector: 'app-milestone-detail',
+ imports: [FormsModule],
+ templateUrl: './milestone-detail.html',
+ styleUrl: './milestone-detail.css',
+})
+export class MilestoneDetail {
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly milestonesStore = inject(MilestonesStore);
+ private readonly issuesStore = inject(IssuesStore);
+ private readonly sanitizer = inject(DomSanitizer);
+
+ protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
+ protected milestone: MilestoneEntity = this.buildMilestone();
+ protected readonly issues = this.issuesStore.issues;
+
+ protected editingDescription = false;
+ protected showAddIssue = false;
+ protected issueSearchQuery = '';
+ protected showIssueSuggestions = false;
+ protected moreMenuOpen = false;
+
+ constructor() {
+ this.milestonesStore.load().then(() => {
+ if (!this.isNewRoute) {
+ const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
+ const found = this.milestonesStore.getById(id);
+ if (found) this.milestone = { ...found };
+ }
+ });
+ this.issuesStore.load();
+
+ this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
+ const id = Number(params.get('id'));
+ if (!id || isNaN(id)) return;
+ const found = this.milestonesStore.getById(id);
+ if (found) {
+ this.milestone = { ...found };
+ this.editingDescription = false;
+ this.showAddIssue = false;
+ }
+ });
+ }
+
+ protected get linkedIssues(): IssueEntity[] {
+ return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
+ }
+
+ protected get availableIssues(): IssueEntity[] {
+ const assignedElsewhere = new Set(
+ this.milestonesStore.milestones()
+ .filter((m) => m.id !== this.milestone.id)
+ .flatMap((m) => m.issueIds),
+ );
+ return this.issues().filter(
+ (i) => !this.milestone.issueIds.includes(i.id) && !assignedElsewhere.has(i.id),
+ );
+ }
+
+ protected get descriptionHtml(): SafeHtml {
+ const html = marked.parse(this.milestone.description || '') as string;
+ return this.sanitizer.bypassSecurityTrustHtml(html);
+ }
+
+ protected get progress(): number {
+ if (this.linkedIssues.length === 0) return 0;
+ return Math.round(
+ (this.linkedIssues.filter((i) => i.status === 'done').length / this.linkedIssues.length) * 100,
+ );
+ }
+
+ protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
+ const map: Record = {
+ 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 get issueSuggestions(): IssueEntity[] {
+ const q = this.issueSearchQuery.trim().toLowerCase();
+ if (!q) return this.availableIssues.slice(0, 8);
+ return this.availableIssues.filter(
+ (i) => i.name.toLowerCase().includes(q) || String(i.id).includes(q),
+ ).slice(0, 8);
+ }
+
+ protected openAddIssue(): void {
+ this.issueSearchQuery = '';
+ this.showIssueSuggestions = false;
+ this.showAddIssue = true;
+ }
+
+ protected cancelAddIssue(): void {
+ this.showAddIssue = false;
+ this.issueSearchQuery = '';
+ this.showIssueSuggestions = false;
+ }
+
+ protected async addIssueFromSearch(id: number): Promise {
+ this.milestone.issueIds = [...this.milestone.issueIds, id];
+ this.issueSearchQuery = '';
+ this.showIssueSuggestions = false;
+ this.showAddIssue = false;
+ await this.saveMilestone();
+ }
+
+ protected hideIssueSuggestions(): void {
+ setTimeout(() => { this.showIssueSuggestions = false; }, 150);
+ }
+
+ protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } {
+ const map: Record = {
+ draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' },
+ todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' },
+ 'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' },
+ done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' },
+ };
+ return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' };
+ }
+
+ protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } {
+ const map: Record = {
+ 'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545' },
+ 'HAUTE': { symbol: '↑', color: '#fd7e14' },
+ 'MOYENNE': { symbol: '–', color: '#ffc107' },
+ 'BASSE': { symbol: '↓', color: '#0d6efd' },
+ 'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d' },
+ };
+ return map[priority] ?? { symbol: '?', color: '#6c757d' };
+ }
+
+ protected async removeIssue(id: number): Promise {
+ this.milestone.issueIds = this.milestone.issueIds.filter((i) => i !== id);
+ await this.saveMilestone();
+ }
+
+ protected onDescriptionPaste(event: ClipboardEvent): void {
+ const ta = event.target as HTMLTextAreaElement;
+ const start = ta.selectionStart;
+ const end = ta.selectionEnd;
+ handleImagePaste(event, (md) => {
+ this.milestone.description = insertAtSelection(ta, this.milestone.description, start, end, md);
+ });
+ }
+
+ protected async saveMilestone(explicit = false): Promise {
+ if (this.isNewRoute && !explicit) return;
+ if (!this.milestone.name.trim()) return;
+ const saved = await this.milestonesStore.upsert(this.milestone);
+ this.milestone = { ...saved };
+ if (this.isNewRoute) {
+ this.router.navigate(['/milestones', saved.id]);
+ }
+ }
+
+ protected cancelCreation(): void {
+ this.router.navigate(['/milestones']);
+ }
+
+ protected async deleteMilestone(): Promise {
+ await this.milestonesStore.deleteById(this.milestone.id);
+ this.router.navigate(['/milestones']);
+ }
+
+ protected toggleMoreMenu(): void {
+ this.moreMenuOpen = !this.moreMenuOpen;
+ }
+
+ protected closeMoreMenu(): void {
+ this.moreMenuOpen = false;
+ }
+
+ protected navigateToIssue(id: number): void {
+ this.router.navigate(['/issues', id]);
+ }
+
+ private buildMilestone(): MilestoneEntity {
+ if (this.route.snapshot.routeConfig?.path === 'milestones/new') {
+ return { id: 0, name: '', description: '', dueDate: '', issueIds: [] };
+ }
+ const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
+ return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] };
+ }
+}
diff --git a/src/app/milestones/milestones-api.service.ts b/src/app/milestones/milestones-api.service.ts
new file mode 100644
index 0000000..dd7ea23
--- /dev/null
+++ b/src/app/milestones/milestones-api.service.ts
@@ -0,0 +1,26 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { API_BASE_URL } from '../issues/issues-api.service';
+import { MilestoneEntity } from './milestones.store';
+
+@Injectable({ providedIn: 'root' })
+export class MilestonesApiService {
+ private readonly http = inject(HttpClient);
+
+ getAll(): Observable {
+ return this.http.get(`${API_BASE_URL}/milestones`);
+ }
+
+ create(milestone: Omit): Observable {
+ return this.http.post(`${API_BASE_URL}/milestones`, milestone);
+ }
+
+ update(id: number, milestone: MilestoneEntity): Observable {
+ return this.http.put(`${API_BASE_URL}/milestones/${id}`, milestone);
+ }
+
+ remove(id: number): Observable {
+ return this.http.delete(`${API_BASE_URL}/milestones/${id}`);
+ }
+}
diff --git a/src/app/milestones/milestones.css b/src/app/milestones/milestones.css
new file mode 100644
index 0000000..641c42a
--- /dev/null
+++ b/src/app/milestones/milestones.css
@@ -0,0 +1,32 @@
+:host {
+ display: block;
+}
+
+.clickable-row {
+ cursor: pointer;
+}
+
+.clickable-row:hover {
+ background-color: #f3f4f6;
+}
+
+.clickable-row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.description-cell {
+ max-width: 280px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-cell {
+ min-width: 130px;
+}
+
+.progress-label {
+ min-width: 2.5rem;
+ text-align: right;
+}
diff --git a/src/app/milestones/milestones.html b/src/app/milestones/milestones.html
new file mode 100644
index 0000000..86ea69d
--- /dev/null
+++ b/src/app/milestones/milestones.html
@@ -0,0 +1,73 @@
+
+
+
+
Milestones
+
Objectifs et jalons du projet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Nom |
+ Description |
+ Échéance |
+ Issues |
+ Progression |
+
+
+
+ @for (milestone of filteredMilestones; track milestone.id) {
+
+ | #{{ milestone.id }} |
+ {{ milestone.name }} |
+ {{ milestone.description }} |
+ {{ formatDate(milestone.dueDate) }} |
+
+ {{ milestone.issueIds.length }}
+ |
+
+
+
+ {{ getProgress(milestone) }}%
+
+ |
+
+ }
+ @if (filteredMilestones.length === 0) {
+
+ | Aucun milestone trouvé. |
+
+ }
+
+
+
+
diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts
new file mode 100644
index 0000000..d844c89
--- /dev/null
+++ b/src/app/milestones/milestones.store.ts
@@ -0,0 +1,76 @@
+import { Injectable, inject, signal } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { MilestonesApiService } from './milestones-api.service';
+
+export type MilestoneEntity = {
+ id: number;
+ name: string;
+ description: string;
+ dueDate: string;
+ issueIds: number[];
+};
+
+@Injectable({ providedIn: 'root' })
+export class MilestonesStore {
+ private readonly api = inject(MilestonesApiService);
+ private readonly data = signal([]);
+
+ readonly loading = signal(false);
+ readonly loaded = signal(false);
+ readonly milestones = this.data.asReadonly();
+
+ getById(id: number): MilestoneEntity | undefined {
+ return this.data().find((m) => m.id === id);
+ }
+
+ async load(): Promise {
+ if (this.loaded()) return;
+ this.loading.set(true);
+ try {
+ const milestones = await firstValueFrom(this.api.getAll());
+ this.data.set(milestones.map((m) => this.normalize(m)));
+ this.loaded.set(true);
+ } finally {
+ this.loading.set(false);
+ }
+ }
+
+ async upsert(milestone: MilestoneEntity): Promise {
+ const normalized = this.normalize(milestone);
+ if (!normalized.id) {
+ const { id: _id, ...body } = normalized;
+ const created = this.normalize(await firstValueFrom(this.api.create(body)));
+ this.data.update((list) => [...list, created]);
+ return created;
+ } else {
+ const updated = this.normalize(
+ await firstValueFrom(this.api.update(normalized.id, normalized)),
+ );
+ this.data.update((list) => {
+ const idx = list.findIndex((m) => m.id === normalized.id);
+ if (idx === -1) return list;
+ const copy = [...list];
+ copy[idx] = updated;
+ return copy;
+ });
+ return updated;
+ }
+ }
+
+ async deleteById(id: number): Promise {
+ await firstValueFrom(this.api.remove(id));
+ this.data.update((list) => list.filter((m) => m.id !== id));
+ }
+
+ private normalize(milestone: Partial): MilestoneEntity {
+ return {
+ id: milestone.id ?? 0,
+ name: milestone.name ?? '',
+ description: milestone.description ?? '',
+ 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
new file mode 100644
index 0000000..8ad6bb5
--- /dev/null
+++ b/src/app/milestones/milestones.ts
@@ -0,0 +1,55 @@
+import { Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { IssuesStore } from '../issues/issues.store';
+import { MilestoneEntity, MilestonesStore } from './milestones.store';
+
+@Component({
+ selector: 'app-milestones',
+ imports: [FormsModule],
+ templateUrl: './milestones.html',
+ styleUrl: './milestones.css',
+})
+export class Milestones {
+ private readonly router = inject(Router);
+ private readonly milestonesStore = inject(MilestonesStore);
+ private readonly issuesStore = inject(IssuesStore);
+
+ constructor() {
+ this.milestonesStore.load();
+ this.issuesStore.load();
+ }
+
+ protected readonly milestones = this.milestonesStore.milestones;
+ protected searchQuery = '';
+
+ protected get filteredMilestones(): MilestoneEntity[] {
+ const q = this.searchQuery.trim().toLowerCase();
+ if (!q) return this.milestones();
+ return this.milestones().filter((m) => m.name.toLowerCase().includes(q));
+ }
+
+ protected getProgress(milestone: MilestoneEntity): number {
+ if (milestone.issueIds.length === 0) return 0;
+ const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id));
+ if (linked.length === 0) return 0;
+ return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100);
+ }
+
+ protected formatDate(iso: string): string {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleDateString('fr-FR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+ }
+
+ protected createMilestone(): void {
+ this.router.navigate(['/milestones/new']);
+ }
+
+ protected openMilestone(id: number): void {
+ this.router.navigate(['/milestones', id]);
+ }
+}