feat(sprints): interface de création et gestion des sprints (frontend)

Ticket #11 — surface UI de gestion des sprints (frontend pur).

- Nouveau composant SprintManager : création (nom optionnel), édition et
  gestion du cycle de vie d'un sprint, contrôles accessibles (pas de DnD).
- useTickets étendu aux opérations de gestion de sprint ; ports, adaptateurs
  (ticket.ts, mock/index.ts) et TicketsPanel câblés en conséquence.
- Tests Vitest associés (tickets.test.tsx).

Typecheck / vitest (497+ tests) / build verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 21:11:37 +02:00
parent edd5486330
commit fa874b976e
8 changed files with 715 additions and 0 deletions

View File

@ -40,6 +40,20 @@ export interface TicketsViewModel {
* version column. Refreshes on the resulting `issueSprintChanged` event.
*/
assignSprint: (ref: string, sprintId: string | null) => Promise<boolean>;
/** Creates a sprint (#11). Returns the created sprint, or `null` on error. */
createSprint: (name: string) => Promise<Sprint | null>;
/** Renames a sprint using its current version (#11). */
renameSprint: (sprintId: string, name: string) => Promise<boolean>;
/**
* Moves a sprint one slot up/down in execution order via `reorderSprints`
* (#11). A no-op at the ends. Accessible (button-driven), non-DnD.
*/
moveSprint: (sprintId: string, direction: "up" | "down") => Promise<boolean>;
/**
* Deletes a sprint (#11). The backend unassigns its tickets (does NOT delete
* them). Refreshes both the sprint list and the tickets.
*/
deleteSprint: (sprintId: string) => Promise<boolean>;
}
function describe(e: unknown): string {
@ -116,6 +130,80 @@ export function useTickets(projectId: string): TicketsViewModel {
[projectId, ticket, refresh, refreshSprints],
);
const createSprint = useCallback(
async (name: string): Promise<Sprint | null> => {
setError(null);
try {
const created = await ticket.createSprint(projectId, name);
await refreshSprints();
return created;
} catch (e) {
setError(describe(e));
return null;
}
},
[projectId, ticket, refreshSprints],
);
const renameSprint = useCallback(
async (sprintId: string, name: string): Promise<boolean> => {
setError(null);
try {
const current = sprints.find((s) => s.id === sprintId);
if (!current) return false;
await ticket.renameSprint(projectId, sprintId, name, current.version);
await refreshSprints();
return true;
} catch (e) {
setError(describe(e));
return false;
}
},
[projectId, ticket, sprints, refreshSprints],
);
const moveSprint = useCallback(
async (sprintId: string, direction: "up" | "down"): Promise<boolean> => {
setError(null);
const ordered = [...sprints].sort((a, b) => a.order - b.order);
const index = ordered.findIndex((s) => s.id === sprintId);
const swapWith = direction === "up" ? index - 1 : index + 1;
if (index === -1 || swapWith < 0 || swapWith >= ordered.length) {
return false;
}
const next = [...ordered];
[next[index], next[swapWith]] = [next[swapWith], next[index]];
try {
await ticket.reorderSprints(
projectId,
next.map((s) => s.id),
);
await refreshSprints();
return true;
} catch (e) {
setError(describe(e));
return false;
}
},
[projectId, ticket, sprints, refreshSprints],
);
const deleteSprint = useCallback(
async (sprintId: string): Promise<boolean> => {
setError(null);
try {
await ticket.deleteSprint(projectId, sprintId);
// Delete unassigns tickets → refresh both projections.
await Promise.all([refresh(), refreshSprints()]);
return true;
} catch (e) {
setError(describe(e));
return false;
}
},
[projectId, ticket, refresh, refreshSprints],
);
useEffect(() => {
void refresh();
}, [refresh]);
@ -157,5 +245,9 @@ export function useTickets(projectId: string): TicketsViewModel {
refresh,
create,
assignSprint,
createSprint,
renameSprint,
moveSprint,
deleteSprint,
};
}