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

@ -1815,6 +1815,7 @@ export class MockTicketGateway implements TicketGateway {
private carnets = new Map<string, Map<string, string>>();
private counters = new Map<string, number>();
private sprints = new Map<string, Map<string, Sprint>>();
private sprintCounters = new Map<string, number>();
constructor(private readonly system?: MockSystemGateway) {}
@ -2141,6 +2142,118 @@ export class MockTicketGateway implements TicketGateway {
});
return structuredClone(ticket);
}
async createSprint(projectId: string, name: string): Promise<Sprint> {
const sprints = this.projectSprints(projectId);
const seq = (this.sprintCounters.get(projectId) ?? 0) + 1;
this.sprintCounters.set(projectId, seq);
const order =
sprints.size === 0
? 1
: Math.max(...[...sprints.values()].map((s) => s.order)) + 1;
const sprint: Sprint = {
id: `mock-sprint-${projectId}-${seq}`,
order,
name,
status: "planned",
ticketCount: 0,
version: 1,
};
sprints.set(sprint.id, sprint);
this.system?.emit({
type: "sprintCreated",
sprintId: sprint.id,
order: sprint.order,
});
return structuredClone(sprint);
}
async renameSprint(
projectId: string,
sprintId: string,
name: string,
expectedVersion: number,
): Promise<Sprint> {
const sprint = this.requireSprint(projectId, sprintId);
this.guardSprint(sprint, expectedVersion);
sprint.name = name;
sprint.version += 1;
this.system?.emit({
type: "sprintRenamed",
sprintId,
name,
version: sprint.version,
});
return structuredClone(sprint);
}
async reorderSprints(
projectId: string,
orderedIds: string[],
): Promise<Sprint[]> {
const sprints = this.projectSprints(projectId);
for (const id of orderedIds) this.requireSprint(projectId, id);
// Assign 1-based order following the provided id sequence; ids omitted from
// `orderedIds` keep their relative order after the listed ones.
const rest = [...sprints.values()]
.filter((s) => !orderedIds.includes(s.id))
.sort((a, b) => a.order - b.order)
.map((s) => s.id);
[...orderedIds, ...rest].forEach((id, index) => {
const sprint = sprints.get(id);
if (sprint) {
sprint.order = index + 1;
this.system?.emit({
type: "sprintReordered",
sprintId: id,
order: sprint.order,
version: sprint.version,
});
}
});
return this.listSprints(projectId);
}
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
this.requireSprint(projectId, sprintId);
// Delete unassigns the sprint's tickets (it does NOT delete them): they fall
// back to the "no sprint" bucket.
for (const ticket of this.projectTickets(projectId).values()) {
if (ticket.sprintId === sprintId) {
ticket.sprintId = null;
this.bump(ticket);
this.system?.emit({
type: "issueSprintChanged",
issueRef: ticket.ref,
from: sprintId,
to: null,
version: ticket.version,
});
}
}
this.projectSprints(projectId).delete(sprintId);
this.system?.emit({ type: "sprintDeleted", sprintId });
}
private requireSprint(projectId: string, sprintId: string): Sprint {
const sprint = this.projectSprints(projectId).get(sprintId);
if (!sprint) {
throw {
code: "NOT_FOUND",
message: `sprint ${sprintId} not found`,
} as GatewayError;
}
return sprint;
}
private guardSprint(sprint: Sprint, expectedVersion: number): void {
if (sprint.version !== expectedVersion) {
throw {
code: "INVALID",
message: `sprint version conflict: expected ${expectedVersion}, actual ${sprint.version}`,
} as GatewayError;
}
}
}
function mostRestrictive(