From 15049c4fe37f81e7826c1a914d1bf1ac741cb2f7 Mon Sep 17 00:00:00 2001 From: Gato Date: Tue, 26 May 2026 21:26:32 +0200 Subject: [PATCH] Ajout milestone --- src/app/app.routes.ts | 5 + src/app/menu/menu.spec.ts | 9 +- src/app/menu/menu.ts | 1 + .../milestone-detail/milestone-detail.css | 203 +++++++++++++++ .../milestone-detail/milestone-detail.html | 233 ++++++++++++++++++ .../milestone-detail/milestone-detail.ts | 200 +++++++++++++++ src/app/milestones/milestones-api.service.ts | 26 ++ src/app/milestones/milestones.css | 32 +++ src/app/milestones/milestones.html | 73 ++++++ src/app/milestones/milestones.store.ts | 76 ++++++ src/app/milestones/milestones.ts | 55 +++++ 11 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 src/app/milestones/milestone-detail/milestone-detail.css create mode 100644 src/app/milestones/milestone-detail/milestone-detail.html create mode 100644 src/app/milestones/milestone-detail/milestone-detail.ts create mode 100644 src/app/milestones/milestones-api.service.ts create mode 100644 src/app/milestones/milestones.css create mode 100644 src/app/milestones/milestones.html create mode 100644 src/app/milestones/milestones.store.ts create mode 100644 src/app/milestones/milestones.ts 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.
+ } +
+
+ + +
+
Informations
+
+
+
+ + +
+ @if (!isNewRoute) { +
+ +
+
+
+
+ {{ progress }}% +
+
+ } +
+
+
+ + +
+
Description
+
+ @if (editingDescription) { + + } @else { +
+ @if (milestone.description) { +
+ } @else { + Ajouter une description... + } +
+ } +
+
+ + +
+
+ Issues liées + {{ milestone.issueIds.length }} +
+ + @if (linkedIssues.length > 0) { +
+ + + + + + + + + + + + + @for (issue of linkedIssues; track issue.id) { + + + + + + + + + } + +
#TypeTitrePrioritéStatut
#{{ issue.id }} + + {{ typeIcon(issue.type).letter }} + + + + + {{ priorityDisplay(issue.priority).symbol }} + + {{ statusBadge(issue.status).label }} + + +
+
+ } + +
+ @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.

+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + @for (milestone of filteredMilestones; track milestone.id) { + + + + + + + + + } + @if (filteredMilestones.length === 0) { + + + + } + +
#NomDescriptionÉchéanceIssuesProgression
#{{ milestone.id }}{{ milestone.name }}{{ milestone.description }}{{ formatDate(milestone.dueDate) }} + {{ milestone.issueIds.length }} + +
+
+
+
+ {{ getProgress(milestone) }}% +
+
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]); + } +}