Ajout milestone

This commit is contained in:
2026-05-26 21:26:32 +02:00
parent 0dc81c7c80
commit 15049c4fe3
11 changed files with 911 additions and 2 deletions
@@ -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: [] };
}
}