Ajout milestone
This commit is contained in:
@@ -2,6 +2,8 @@ import { Routes } from '@angular/router';
|
|||||||
import { Home } from './home/home';
|
import { Home } from './home/home';
|
||||||
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
||||||
import { Issues } from './issues/issues';
|
import { Issues } from './issues/issues';
|
||||||
|
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
|
||||||
|
import { Milestones } from './milestones/milestones';
|
||||||
import { Projects } from './projects/projects';
|
import { Projects } from './projects/projects';
|
||||||
import { authGuard } from './auth/auth.guard';
|
import { authGuard } from './auth/auth.guard';
|
||||||
|
|
||||||
@@ -13,5 +15,8 @@ export const routes: Routes = [
|
|||||||
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
||||||
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
|
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
|
||||||
{ path: 'issues', component: Issues, 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' },
|
{ path: '**', redirectTo: 'home' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ describe('Menu', () => {
|
|||||||
expect(component).toBeTruthy();
|
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 }[];
|
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', () => {
|
it('should contain Issues link', () => {
|
||||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
const items = (component as any).menuItems as { label: string; path: string }[];
|
||||||
expect(items.some((i) => i.path === '/issues')).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class Menu {
|
|||||||
{ label: 'Accueil', path: '/home' },
|
{ label: 'Accueil', path: '/home' },
|
||||||
{ label: 'Projet', path: '/project' },
|
{ label: 'Projet', path: '/project' },
|
||||||
{ label: 'Issues', path: '/issues' },
|
{ label: 'Issues', path: '/issues' },
|
||||||
|
{ label: 'Milestones', path: '/milestones' },
|
||||||
];
|
];
|
||||||
|
|
||||||
protected logout(): void {
|
protected logout(): void {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<!-- suppress HtmlUnknownAttribute -->
|
||||||
|
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelCreation()">
|
||||||
|
← Milestones
|
||||||
|
</button>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
@if (!isNewRoute) {
|
||||||
|
<div class="more-wrapper">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
|
||||||
|
@if (moreMenuOpen) {
|
||||||
|
<div class="more-menu dropdown-menu show">
|
||||||
|
<button type="button" class="dropdown-item text-danger" (click)="deleteMilestone()">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (moreMenuOpen) {
|
||||||
|
<div class="more-backdrop" (click)="closeMoreMenu()"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<input
|
||||||
|
#nameInput="ngModel"
|
||||||
|
aria-label="Nom du milestone"
|
||||||
|
class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
|
||||||
|
[class.is-invalid]="nameInput.invalid && nameInput.touched"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom du milestone..."
|
||||||
|
required
|
||||||
|
[(ngModel)]="milestone.name"
|
||||||
|
(blur)="saveMilestone()"
|
||||||
|
/>
|
||||||
|
@if (nameInput.invalid && nameInput.touched) {
|
||||||
|
<div class="text-danger small mt-1">Le nom est obligatoire.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Infos -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<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 d'échéance</label>
|
||||||
|
<input
|
||||||
|
aria-label="Date d'échéance"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="milestone.dueDate"
|
||||||
|
(blur)="saveMilestone()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (!isNewRoute) {
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="field-label">Progression</label>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<div class="progress flex-grow-1" style="height: 8px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
[style.width.%]="progress"
|
||||||
|
[attr.aria-valuenow]="progress"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-secondary small" style="min-width: 2.5rem; text-align: right;">{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header section-header">Description</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (editingDescription) {
|
||||||
|
<textarea
|
||||||
|
aria-label="Description"
|
||||||
|
class="form-control border-0 shadow-none p-0 description-textarea"
|
||||||
|
placeholder="Ajouter une description... (Markdown supporté, coller une image avec Ctrl+V)"
|
||||||
|
[(ngModel)]="milestone.description"
|
||||||
|
(blur)="editingDescription = false; saveMilestone()"
|
||||||
|
(paste)="onDescriptionPaste($event)"
|
||||||
|
></textarea>
|
||||||
|
} @else {
|
||||||
|
<div
|
||||||
|
class="description-preview"
|
||||||
|
(click)="editingDescription = true"
|
||||||
|
title="Cliquer pour éditer"
|
||||||
|
>
|
||||||
|
@if (milestone.description) {
|
||||||
|
<div class="markdown-body" [innerHTML]="descriptionHtml"></div>
|
||||||
|
} @else {
|
||||||
|
<span class="description-placeholder">Ajouter une description...</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Issues liées -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header section-header d-flex align-items-center justify-content-between">
|
||||||
|
<span>Issues liées</span>
|
||||||
|
<span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (linkedIssues.length > 0) {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">#</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Titre</th>
|
||||||
|
<th>Priorité</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (issue of linkedIssues; track issue.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 text-secondary small">#{{ issue.id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="type-icon" [style.background]="typeIcon(issue.type).bg" [title]="issue.type">
|
||||||
|
{{ typeIcon(issue.type).letter }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="issue-name-btn" (click)="navigateToIssue(issue.id)">
|
||||||
|
{{ issue.name }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
[style.color]="priorityDisplay(issue.priority).color"
|
||||||
|
style="font-weight: 700; font-size: 1rem; letter-spacing: -1px;"
|
||||||
|
[title]="issue.priority"
|
||||||
|
>{{ priorityDisplay(issue.priority).symbol }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
[style.background]="statusBadge(issue.status).bg"
|
||||||
|
[style.color]="statusBadge(issue.status).color"
|
||||||
|
>{{ statusBadge(issue.status).label }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dep-remove"
|
||||||
|
aria-label="Retirer"
|
||||||
|
(click)="removeIssue(issue.id)"
|
||||||
|
>×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card-body" [class.pt-0]="linkedIssues.length > 0">
|
||||||
|
@if (showAddIssue) {
|
||||||
|
<div class="issue-search-wrapper">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Rechercher par nom ou #id..."
|
||||||
|
[(ngModel)]="issueSearchQuery"
|
||||||
|
(focus)="showIssueSuggestions = true"
|
||||||
|
(blur)="hideIssueSuggestions()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
(click)="cancelAddIssue()"
|
||||||
|
aria-label="Annuler"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
@if (showIssueSuggestions && issueSuggestions.length > 0) {
|
||||||
|
<ul class="issue-suggestions">
|
||||||
|
@for (issue of issueSuggestions; track issue.id) {
|
||||||
|
<li>
|
||||||
|
<button type="button" class="issue-suggestion-item" (mousedown)="addIssueFromSearch(issue.id)">
|
||||||
|
<span class="text-secondary small me-1">#{{ issue.id }}</span>
|
||||||
|
<span class="type-icon me-1" [style.background]="typeIcon(issue.type).bg">{{ typeIcon(issue.type).letter }}</span>
|
||||||
|
{{ issue.name }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
@if (showIssueSuggestions && issueSearchQuery && issueSuggestions.length === 0) {
|
||||||
|
<div class="issue-suggestions text-secondary small p-2">Aucune issue trouvée.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
[disabled]="availableIssues.length === 0"
|
||||||
|
(click)="openAddIssue()"
|
||||||
|
>+ Ajouter une issue</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons de création -->
|
||||||
|
@if (isNewRoute) {
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
[disabled]="!milestone.name.trim()"
|
||||||
|
(click)="saveMilestone(true)"
|
||||||
|
>Créer</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" (click)="cancelCreation()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<IssueEntity['type'], { letter: string; bg: string }> = {
|
||||||
|
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<void> {
|
||||||
|
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<IssueEntity['status'], { label: string; bg: string; color: string }> = {
|
||||||
|
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<IssueEntity['priority'], { symbol: string; color: string }> = {
|
||||||
|
'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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MilestoneEntity[]> {
|
||||||
|
return this.http.get<MilestoneEntity[]>(`${API_BASE_URL}/milestones`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(milestone: Omit<MilestoneEntity, 'id'>): Observable<MilestoneEntity> {
|
||||||
|
return this.http.post<MilestoneEntity>(`${API_BASE_URL}/milestones`, milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: number, milestone: MilestoneEntity): Observable<MilestoneEntity> {
|
||||||
|
return this.http.put<MilestoneEntity>(`${API_BASE_URL}/milestones/${id}`, milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${API_BASE_URL}/milestones/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- suppress HtmlUnknownAttribute -->
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Rechercher un milestone..."
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
style="max-width: 300px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
<th>Issues</th>
|
||||||
|
<th>Progression</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (milestone of filteredMilestones; track milestone.id) {
|
||||||
|
<tr
|
||||||
|
class="clickable-row"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openMilestone(milestone.id)"
|
||||||
|
(keydown.enter)="openMilestone(milestone.id)"
|
||||||
|
>
|
||||||
|
<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.dueDate) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary rounded-pill">{{ milestone.issueIds.length }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="progress-cell">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress flex-grow-1" style="height: 6px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
[style.width.%]="getProgress(milestone)"
|
||||||
|
[attr.aria-valuenow]="getProgress(milestone)"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-label text-secondary small">{{ getProgress(milestone) }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (filteredMilestones.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-secondary py-4">Aucun milestone trouvé.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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<MilestoneEntity[]>([]);
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<MilestoneEntity> {
|
||||||
|
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<void> {
|
||||||
|
await firstValueFrom(this.api.remove(id));
|
||||||
|
this.data.update((list) => list.filter((m) => m.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(milestone: Partial<MilestoneEntity>): 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')
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user