Merge pull request 'Ordre des status' (#31) from feat/30-ordre-status into develop

Reviewed-on: Bonsai/Bonsai-webapp#31
This commit is contained in:
2026-05-28 21:14:23 +02:00
15 changed files with 667 additions and 24 deletions
+5 -3
View File
@@ -4,14 +4,16 @@
Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend. Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend.
## Action requise ## Action requise
Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé en kebab-case selon le besoin : Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé d'après le **numéro de ticket** extrait du nom de la branche courante, suivi du slug de la branche :
``` ```
api-issues/nom-du-besoin.md api-issues/<numéro>-<slug>.md
``` ```
> **Exemple** : branche `feat/30-ordre-statut` → fichier `api-issues/30-ordre-statut.md`
## Contenu du fichier ## Contenu du fichier
Le fichier doit décrire : Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire :
1. **Contexte** — quelle fonctionnalité frontend nécessite cette évolution 1. **Contexte** — quelle fonctionnalité frontend nécessite cette évolution
2. **Problème** — ce qui manque ou bloque dans l'API actuelle 2. **Problème** — ce qui manque ou bloque dans l'API actuelle
3. **Besoin** — le ou les endpoints à créer / modifier, avec le corps de requête et la réponse attendus 3. **Besoin** — le ou les endpoints à créer / modifier, avec le corps de requête et la réponse attendus
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)" "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)",
"Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
+2 -2
View File
@@ -6,7 +6,7 @@ import { Issues } from './issues/issues';
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail'; import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
import { Milestones } from './milestones/milestones'; import { Milestones } from './milestones/milestones';
import { Projects } from './projects/projects'; import { Projects } from './projects/projects';
import { Settings } from './settings/settings'; import { Statuses } from './statuses/statuses';
import { authGuard } from './auth/auth.guard'; import { authGuard } from './auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
@@ -21,6 +21,6 @@ export const routes: Routes = [
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] }, { path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] }, { path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
{ path: 'milestones', component: Milestones, canActivate: [authGuard] }, { path: 'milestones', component: Milestones, canActivate: [authGuard] },
{ path: 'settings', component: Settings, canActivate: [authGuard] }, { path: 'statuses', component: Statuses, canActivate: [authGuard] },
{ path: '**', redirectTo: 'home' }, { path: '**', redirectTo: 'home' },
]; ];
+5 -5
View File
@@ -100,11 +100,11 @@ export class Dashboard {
protected formatDate(iso: string): string { protected formatDate(iso: string): string {
if (!iso) return '—'; if (!iso) return '—';
return new Date(iso).toLocaleDateString('fr-FR', { const d = new Date(iso);
day: '2-digit', const day = String(d.getDate()).padStart(2, '0');
month: '2-digit', const month = String(d.getMonth() + 1).padStart(2, '0');
year: 'numeric', const year = d.getFullYear();
}); return `${day}/${month}/${year}`;
} }
protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } { protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
@@ -29,7 +29,7 @@
@if (statusMenuOpen) { @if (statusMenuOpen) {
<div class="status-backdrop" (click)="closeStatusMenu()"></div> <div class="status-backdrop" (click)="closeStatusMenu()"></div>
<ul class="status-dropdown dropdown-menu show"> <ul class="status-dropdown dropdown-menu show">
@for (status of statusOptions; track status.id) { @for (status of statusOptions(); track status.id) {
<li> <li>
<button <button
type="button" type="button"
+3 -5
View File
@@ -8,7 +8,7 @@ import { IssueEntity, IssuesStore } from '../issues.store';
import { IssueComments } from '../issue-comments/issue-comments'; import { IssueComments } from '../issue-comments/issue-comments';
import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
@Component({ @Component({
selector: 'app-issue-detail', selector: 'app-issue-detail',
@@ -21,8 +21,8 @@ export class IssueDetail {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore);
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
protected readonly statusesStore = inject(StatusesStore);
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
protected issue: IssueEntity = this.buildIssue(); protected issue: IssueEntity = this.buildIssue();
@@ -66,9 +66,7 @@ export class IssueDetail {
protected showCreateInEpic = false; protected showCreateInEpic = false;
protected newIssueName = ''; protected newIssueName = '';
protected get statusOptions(): StatusEntity[] { protected readonly statusOptions = this.statusesStore.statuses;
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
}
protected readonly typeOptions: IssueEntity['type'][] = [ protected readonly typeOptions: IssueEntity['type'][] = [
'Epic', 'Epic',
+1 -1
View File
@@ -69,7 +69,7 @@
</button> </button>
</li> </li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@for (status of statusOptions; track status.id) { @for (status of statusOptions(); track status.id) {
<li> <li>
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status.id, $event)"> <button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status.id, $event)">
<span class="filter-check">@if (selectedStatuses.has(status.id)) { ✓ }</span> <span class="filter-check">@if (selectedStatuses.has(status.id)) { ✓ }</span>
+3 -5
View File
@@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store'; import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../settings/statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
@Component({ @Component({
selector: 'app-issues', selector: 'app-issues',
@@ -15,7 +15,7 @@ export class Issues {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
protected readonly statusesStore = inject(StatusesStore); private readonly statusesStore = inject(StatusesStore);
constructor() { constructor() {
this.issuesStore.load(); this.issuesStore.load();
@@ -35,9 +35,7 @@ export class Issues {
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story', 'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
]; ];
protected get statusOptions(): StatusEntity[] { protected readonly statusOptions = this.statusesStore.statuses;
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
}
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined { protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
return this.milestones().find((m) => m.issueIds.includes(issueId)); return this.milestones().find((m) => m.issueIds.includes(issueId));
+1 -1
View File
@@ -19,7 +19,7 @@ export class Menu {
{ label: 'Projet', path: '/project' }, { label: 'Projet', path: '/project' },
{ label: 'Issues', path: '/issues' }, { label: 'Issues', path: '/issues' },
{ label: 'Milestones', path: '/milestones' }, { label: 'Milestones', path: '/milestones' },
{ label: 'Paramètres', path: '/settings' }, { label: 'Statuts', path: '/statuses' },
]; ];
protected logout(): void { protected logout(): void {
+137
View File
@@ -0,0 +1,137 @@
:host {
display: block;
}
.statuses-page {
max-width: 560px;
margin: 2rem auto;
padding: 0 1rem;
}
.statuses-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.statuses-title {
font-size: 1.375rem;
font-weight: 600;
margin-bottom: 0.375rem;
color: #111827;
}
.statuses-subtitle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.statuses-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
cursor: grab;
user-select: none;
transition: box-shadow 0.15s, border-color 0.15s, opacity 0.15s;
}
.status-item:active {
cursor: grabbing;
}
.status-item--dragging {
opacity: 0.4;
}
.status-item--drag-over {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25);
}
.status-item__handle {
font-size: 1.125rem;
color: #9ca3af;
line-height: 1;
flex-shrink: 0;
}
.status-item__position {
font-size: 0.75rem;
font-weight: 600;
color: #9ca3af;
width: 1.25rem;
text-align: center;
flex-shrink: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}
.status-id {
font-size: 0.8rem;
flex: 1;
}
.status-item__actions {
margin-left: auto;
flex-shrink: 0;
}
.status-form-colors {
display: flex;
gap: 1.5rem;
align-items: flex-end;
flex-wrap: wrap;
}
.color-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-control-color {
width: 3rem;
height: 2rem;
padding: 0.1rem;
cursor: pointer;
}
.color-hex {
font-size: 0.8rem;
color: #64748b;
font-family: monospace;
}
.status-form-preview {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
+108
View File
@@ -0,0 +1,108 @@
<div class="statuses-page">
<div class="statuses-header">
<div>
<h1 class="statuses-title">Statuts</h1>
<p class="statuses-subtitle">Glissez-déposez pour réordonner. Cliquez sur Modifier pour changer les couleurs ou le libellé.</p>
</div>
@if (formMode() === null) {
<button class="btn btn-primary btn-sm" (click)="openCreate()">+ Nouveau statut</button>
}
</div>
@if (formMode() !== null) {
<div class="status-form card mb-4">
<div class="card-body">
<h3 class="card-title h6 mb-3">
{{ formMode() === 'create' ? 'Créer un statut' : 'Modifier le statut' }}
</h3>
@if (formMode() === 'create') {
<div class="mb-3">
<label class="form-label form-label-sm" for="status-id">Identifiant</label>
<input
id="status-id"
class="form-control form-control-sm"
type="text"
placeholder="ex: en-attente"
[(ngModel)]="form.id"
/>
<div class="form-text">Généré automatiquement à partir du libellé si vide.</div>
@if (idError()) {
<div class="text-danger small mt-1">{{ idError() }}</div>
}
</div>
}
<div class="mb-3">
<label class="form-label form-label-sm" for="status-label">Libellé</label>
<input
id="status-label"
class="form-control form-control-sm"
type="text"
placeholder="ex: EN ATTENTE"
[(ngModel)]="form.label"
/>
</div>
<div class="status-form-colors mb-3">
<div>
<label class="form-label form-label-sm" for="status-bg">Couleur de fond</label>
<div class="color-input-row">
<input id="status-bg" class="form-control form-control-color" type="color" [(ngModel)]="form.bg" />
<span class="color-hex">{{ form.bg }}</span>
</div>
</div>
<div>
<label class="form-label form-label-sm" for="status-color">Couleur du texte</label>
<div class="color-input-row">
<input id="status-color" class="form-control form-control-color" type="color" [(ngModel)]="form.color" />
<span class="color-hex">{{ form.color }}</span>
</div>
</div>
<div class="status-form-preview">
<label class="form-label form-label-sm">Aperçu</label>
<span class="status-badge" [style.background]="form.bg" [style.color]="form.color">
{{ form.label || 'LIBELLÉ' }}
</span>
</div>
</div>
<div class="status-form-actions">
<button class="btn btn-secondary btn-sm" (click)="cancel()">Annuler</button>
<button class="btn btn-primary btn-sm" [disabled]="!isFormValid()" (click)="save()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
}
<div class="statuses-list">
@for (status of statuses(); track status.id; let i = $index) {
<div
class="status-item"
[class.status-item--drag-over]="dragOverIndex === i && dragIndex !== i"
[class.status-item--dragging]="dragIndex === i"
draggable="true"
(dragstart)="onDragStart(i)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<span class="status-item__handle" aria-hidden="true"></span>
<span class="status-item__position">{{ i + 1 }}</span>
<span class="status-badge" [style.background]="status.bg" [style.color]="status.color">{{ status.label }}</span>
<span class="status-id text-muted">{{ status.id }}</span>
<div class="status-item__actions">
<button
class="btn btn-outline-secondary btn-sm"
[disabled]="formMode() !== null"
(click)="openEdit(status)"
>Modifier</button>
</div>
</div>
} @empty {
<p class="text-muted">Aucun statut configuré.</p>
}
</div>
</div>
+113
View File
@@ -0,0 +1,113 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { Statuses } from './statuses';
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
describe('Statuses', () => {
let fixture: ComponentFixture<Statuses>;
let component: Statuses;
let store: StatusesStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Statuses],
}).compileComponents();
store = TestBed.inject(StatusesStore);
fixture = TestBed.createComponent(Statuses);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('drag and drop', () => {
it('sets dragIndex on dragstart', () => {
(component as any).onDragStart(2);
expect((component as any).dragIndex).toBe(2);
});
it('sets dragOverIndex on dragover', () => {
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
(component as any).onDragOver(event, 1);
expect((component as any).dragOverIndex).toBe(1);
});
it('reorders statuses on drop', () => {
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
(component as any).onDragStart(0);
(component as any).onDrop(event, 2);
expect(store.statuses()[0].id).toBe('todo');
expect(store.statuses()[2].id).toBe('draft');
});
it('does nothing when dropped on same index', () => {
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
const originalIds = store.statuses().map((s) => s.id);
(component as any).onDragStart(1);
(component as any).onDrop(event, 1);
expect(store.statuses().map((s) => s.id)).toEqual(originalIds);
});
it('resets drag state after drop', () => {
const event = { preventDefault: vi.fn() } as unknown as DragEvent;
(component as any).onDragStart(0);
(component as any).onDrop(event, 1);
expect((component as any).dragIndex).toBeNull();
expect((component as any).dragOverIndex).toBeNull();
});
it('resets drag state on dragend', () => {
(component as any).onDragStart(1);
(component as any).onDragEnd();
expect((component as any).dragIndex).toBeNull();
expect((component as any).dragOverIndex).toBeNull();
});
});
describe('create form', () => {
it('opens the create form', () => {
(component as any).openCreate();
expect((component as any).formMode()).toBe('create');
});
it('cancels the form', () => {
(component as any).openCreate();
(component as any).cancel();
expect((component as any).formMode()).toBeNull();
});
it('saves a new status', () => {
(component as any).openCreate();
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000' };
(component as any).save();
expect(store.getById('test-id')?.label).toBe('TEST');
});
it('reports duplicate id error', () => {
(component as any).openCreate();
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000' };
(component as any).save();
expect((component as any).idError()).toBeTruthy();
});
});
describe('edit form', () => {
it('opens the edit form with existing status values', () => {
const status = DEFAULT_STATUSES[0];
(component as any).openEdit(status);
expect((component as any).formMode()).toBe('edit');
expect((component as any).form.label).toBe(status.label);
});
it('saves updated label', () => {
const status = DEFAULT_STATUSES[0];
(component as any).openEdit(status);
(component as any).form = { ...status, label: 'MODIFIÉ' };
(component as any).save();
expect(store.getById(status.id)?.label).toBe('MODIFIÉ');
});
});
});
+111
View File
@@ -0,0 +1,111 @@
import { TestBed } from '@angular/core/testing';
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
const STORAGE_KEY = 'bonsai_statuses';
describe('StatusesStore', () => {
let store: StatusesStore;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
TestBed.configureTestingModule({});
store = TestBed.inject(StatusesStore);
});
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('statuses', () => {
it('returns default statuses when localStorage is empty', () => {
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
});
it('restores statuses saved in localStorage', () => {
const saved = [
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 0 },
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 1 },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses()).toEqual(saved);
});
it('falls back to default when localStorage contains invalid JSON', () => {
localStorage.setItem(STORAGE_KEY, 'not-json');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses()).toEqual(DEFAULT_STATUSES);
});
it('sorts statuses by order on load', () => {
const unsorted = [DEFAULT_STATUSES[2], DEFAULT_STATUSES[0], DEFAULT_STATUSES[1], DEFAULT_STATUSES[3]];
localStorage.setItem(STORAGE_KEY, JSON.stringify(unsorted));
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses().map((s) => s.id)).toEqual(['draft', 'todo', 'in-progress', 'done']);
});
});
describe('setOrder', () => {
it('reassigns order values based on position', () => {
const reordered = [DEFAULT_STATUSES[3], DEFAULT_STATUSES[2], DEFAULT_STATUSES[1], DEFAULT_STATUSES[0]];
store.setOrder(reordered);
expect(store.statuses()[0].id).toBe('done');
expect(store.statuses()[0].order).toBe(0);
expect(store.statuses()[3].id).toBe('draft');
expect(store.statuses()[3].order).toBe(3);
});
it('persists the new order to localStorage', () => {
const reordered = [DEFAULT_STATUSES[1], DEFAULT_STATUSES[0], DEFAULT_STATUSES[2], DEFAULT_STATUSES[3]];
store.setOrder(reordered);
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored[0].id).toBe('todo');
expect(stored[1].id).toBe('draft');
});
});
describe('getById', () => {
it('returns the matching status entity', () => {
expect(store.getById('draft')?.label).toBe('BROUILLON');
});
it('returns undefined for unknown id', () => {
expect(store.getById('unknown')).toBeUndefined();
});
});
describe('create', () => {
it('adds a new status with the next order value', () => {
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b' });
const added = store.getById('blocked');
expect(added?.label).toBe('BLOQUÉ');
expect(added?.order).toBe(DEFAULT_STATUSES.length);
});
it('persists the new status to localStorage', () => {
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000' });
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.some((s: { id: string }) => s.id === 'custom')).toBe(true);
});
});
describe('update', () => {
it('updates label, bg and color of an existing status', () => {
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000' });
const updated = store.getById('draft');
expect(updated?.label).toBe('NOUVEAU');
expect(updated?.bg).toBe('#fff');
});
it('does not change unrelated statuses', () => {
store.update('draft', { label: 'NOUVEAU' });
expect(store.getById('todo')?.label).toBe('À FAIRE');
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import { Injectable, signal } from '@angular/core';
export type StatusEntity = {
id: string;
label: string;
bg: string;
color: string;
order: number;
};
export const DEFAULT_STATUSES: StatusEntity[] = [
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2 },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3 },
];
const STORAGE_KEY = 'bonsai_statuses';
@Injectable({ providedIn: 'root' })
export class StatusesStore {
private readonly data = signal<StatusEntity[]>(this.loadFromStorage());
readonly statuses = this.data.asReadonly();
getById(id: string): StatusEntity | undefined {
return this.data().find((s) => s.id === id);
}
create(status: Omit<StatusEntity, 'order'>): void {
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
this.data.update((list) => [...list, { ...status, order: maxOrder + 1 }]);
this.saveToStorage();
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
this.data.update((list) =>
list.map((s) => (s.id === id ? { ...s, ...changes } : s)),
);
this.saveToStorage();
}
setOrder(ordered: StatusEntity[]): void {
this.data.set(ordered.map((s, i) => ({ ...s, order: i })));
this.saveToStorage();
}
private saveToStorage(): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data()));
}
private loadFromStorage(): StatusEntity[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StatusEntity[];
if (Array.isArray(parsed) && parsed.length > 0) {
return [...parsed].sort((a, b) => a.order - b.order);
}
}
} catch {
// ignore parse errors
}
return [...DEFAULT_STATUSES];
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { StatusEntity, StatusesStore } from './statuses.store';
type FormMode = 'create' | 'edit';
type StatusForm = { id: string; label: string; bg: string; color: string };
@Component({
selector: 'app-statuses',
imports: [FormsModule],
templateUrl: './statuses.html',
styleUrl: './statuses.css',
})
export class Statuses {
protected readonly statusesStore = inject(StatusesStore);
protected readonly statuses = this.statusesStore.statuses;
protected dragIndex: number | null = null;
protected dragOverIndex: number | null = null;
protected formMode = signal<FormMode | null>(null);
protected editingId = signal<string | null>(null);
protected form: StatusForm = this.emptyForm();
protected idError = signal<string | null>(null);
protected openCreate(): void {
this.form = this.emptyForm();
this.idError.set(null);
this.editingId.set(null);
this.formMode.set('create');
}
protected openEdit(status: StatusEntity): void {
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
this.idError.set(null);
this.editingId.set(status.id);
this.formMode.set('edit');
}
protected cancel(): void {
this.formMode.set(null);
this.editingId.set(null);
}
protected save(): void {
if (this.formMode() === 'create') {
const id = this.slugify(this.form.id || this.form.label);
if (!id) return;
if (this.statusesStore.getById(id)) {
this.idError.set('Un statut avec cet identifiant existe déjà.');
return;
}
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
} else {
const id = this.editingId();
if (!id) return;
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
}
this.formMode.set(null);
this.editingId.set(null);
}
protected isFormValid(): boolean {
return this.form.label.trim().length > 0;
}
protected onDragStart(index: number): void {
this.dragIndex = index;
}
protected onDragOver(event: DragEvent, index: number): void {
event.preventDefault();
this.dragOverIndex = index;
}
protected onDrop(event: DragEvent, dropIndex: number): void {
event.preventDefault();
if (this.dragIndex === null || this.dragIndex === dropIndex) {
this.dragIndex = null;
this.dragOverIndex = null;
return;
}
const newOrder = [...this.statuses()];
const [dragged] = newOrder.splice(this.dragIndex, 1);
newOrder.splice(dropIndex, 0, dragged);
this.statusesStore.setOrder(newOrder);
this.dragIndex = null;
this.dragOverIndex = null;
}
protected onDragEnd(): void {
this.dragIndex = null;
this.dragOverIndex = null;
}
private slugify(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
private emptyForm(): StatusForm {
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
}
}