diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 1e19809..7caa049 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1815,6 +1815,7 @@ export class MockTicketGateway implements TicketGateway { private carnets = new Map>(); private counters = new Map(); private sprints = new Map>(); + private sprintCounters = new Map(); constructor(private readonly system?: MockSystemGateway) {} @@ -2141,6 +2142,118 @@ export class MockTicketGateway implements TicketGateway { }); return structuredClone(ticket); } + + async createSprint(projectId: string, name: string): Promise { + 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 { + 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 { + 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 { + 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( diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 4c7d02d..ff8cf3f 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -148,4 +148,38 @@ export class TauriTicketGateway implements TicketGateway { request: { projectId, ref, sprintId, expectedVersion }, }); } + + async createSprint(projectId: string, name: string): Promise { + return invoke("sprint_create", { + request: { projectId, name }, + }); + } + + async renameSprint( + projectId: string, + sprintId: string, + name: string, + expectedVersion: number, + ): Promise { + return invoke("sprint_rename", { + request: { projectId, sprintId, name, expectedVersion }, + }); + } + + async reorderSprints( + projectId: string, + orderedIds: string[], + ): Promise { + // `sprint_reorder` returns a `SprintListDto { items }`; unwrap to the array. + const list = await invoke<{ items: Sprint[] }>("sprint_reorder", { + request: { projectId, orderedIds }, + }); + return list.items; + } + + async deleteSprint(projectId: string, sprintId: string): Promise { + await invoke("sprint_delete", { + request: { projectId, sprintId }, + }); + } } diff --git a/frontend/src/features/tickets/SprintManager.tsx b/frontend/src/features/tickets/SprintManager.tsx new file mode 100644 index 0000000..8de7c84 --- /dev/null +++ b/frontend/src/features/tickets/SprintManager.tsx @@ -0,0 +1,286 @@ +/** + * Sprint management overlay (ticket #11, F3). + * + * A full-screen dialog to create, rename, reorder (accessible up/down buttons, + * no drag-and-drop required) and delete sprints, plus add/remove tickets to a + * sprint. All behaviour comes from {@link useTickets} via the injected view-model + * — this component is pure presentation over the same hook instance the panel + * uses, so mutations reflect live. + * + * Deleting a sprint UNASSIGNS its tickets (they fall back to the backlog); it + * never deletes tickets — the confirmation says so explicitly. + */ + +import { useState } from "react"; + +import { Button, Input, cn } from "@/shared"; +import { TicketRef } from "./ticketMeta"; +import type { TicketsViewModel } from "./useTickets"; + +const selectClass = cn( + "h-8 rounded-md bg-raised px-2 text-xs text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +export interface SprintManagerProps { + vm: TicketsViewModel; + onClose: () => void; +} + +export function SprintManager({ vm, onClose }: SprintManagerProps) { + const items = vm.list?.items ?? []; + const sprints = [...vm.sprints].sort((a, b) => a.order - b.order); + + const [newName, setNewName] = useState(""); + // Local rename drafts keyed by sprint id; falls back to the stored name. + const [renameDrafts, setRenameDrafts] = useState>({}); + // Sprint pending delete confirmation, or null. + const [confirmDelete, setConfirmDelete] = useState< + { id: string; name: string } | null + >(null); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + // Name is optional in the UI; default to a positional name (the backend + // requires a non-empty name). + const name = newName.trim() || `Sprint ${sprints.length + 1}`; + const created = await vm.createSprint(name); + if (created) setNewName(""); + } + + return ( +
+ {/* ── Header ── */} +
+ Manage sprints + +
+ + {vm.error && ( +

+ {vm.error} +

+ )} + +
+ {/* ── Create ── */} +
+ setNewName(e.target.value)} + className="max-w-xs" + /> + +
+ + {sprints.length === 0 ? ( +

No sprints yet.

+ ) : ( +
    + {sprints.map((sprint, index) => { + const inSprint = items.filter((t) => t.sprintId === sprint.id); + const addable = items.filter((t) => t.sprintId !== sprint.id); + const draft = renameDrafts[sprint.id] ?? sprint.name; + return ( +
  • + {/* ── Row header: order, rename, reorder, delete ── */} +
    + + #{sprint.order} + + + setRenameDrafts((prev) => ({ + ...prev, + [sprint.id]: e.target.value, + })) + } + className="max-w-xs" + /> + +
    + + + +
    +
    + + {/* ── Tickets in this sprint ── */} +
    + {inSprint.length === 0 ? ( +

    No tickets in this sprint.

    + ) : ( +
      + {inSprint.map((t) => ( +
    • + + + {t.title} + + +
    • + ))} +
    + )} + + {/* Add an existing ticket (backlog or another sprint). */} + +
    +
  • + ); + })} +
+ )} +
+ + {/* ── Delete confirmation ── */} + {confirmDelete && ( +
+
+

+ Supprimer le sprint « {confirmDelete.name} » ? +

+

+ Les tickets de ce sprint ne seront pas supprimés : ils seront + simplement désassignés et retourneront au backlog « Sans + sprint ». +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index 5ad5f7d..3373edf 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -18,6 +18,7 @@ import type { import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; +import { SprintManager } from "./SprintManager"; import { PriorityBadge, StatusBadge, @@ -44,6 +45,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const vm = useTickets(projectId); const { agents, nameOf } = useProjectAgents(projectId); const [showCreate, setShowCreate] = useState(false); + const [showSprints, setShowSprints] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newPriority, setNewPriority] = useState("medium"); @@ -90,6 +92,14 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { > {showCreate ? "Cancel" : "New"} +