diff --git a/frontend/src/features/tickets/index.ts b/frontend/src/features/tickets/index.ts index f63540c..ecbbe52 100644 --- a/frontend/src/features/tickets/index.ts +++ b/frontend/src/features/tickets/index.ts @@ -36,4 +36,7 @@ export { statusLabel, priorityLabel, linkKindLabel, + TICKET_STATUSES, + TICKET_PRIORITIES, + TICKET_LINK_KINDS, } from "./ticketMeta"; diff --git a/frontend/src/features/tickets/useTicketDetail.ts b/frontend/src/features/tickets/useTicketDetail.ts index e867e9b..e9d68f6 100644 --- a/frontend/src/features/tickets/useTicketDetail.ts +++ b/frontend/src/features/tickets/useTicketDetail.ts @@ -48,6 +48,12 @@ export interface TicketDetailViewModel { link: (targetRef: string, kind: TicketLinkKind) => Promise; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise; assign: (agentId: string, assigned: boolean) => Promise; + /** + * Changes this ticket's sprint membership, or clears it with + * `sprintId === null` (ticket #86 — web sprint control in the detail view). + * Routes to `ticket_assign_sprint`/`ticket_unassign_sprint` via the gateway. + */ + setSprint: (sprintId: string | null) => Promise; /** * Deletes this ticket (ticket #6). Returns `true` on success. The removal from * lists and the closing of this surface flow from the resulting `issueDeleted` @@ -205,6 +211,12 @@ export function useTicketDetail( [run, gateway, projectId, ref], ); + const setSprint: TicketDetailViewModel["setSprint"] = useCallback( + (sprintId) => + run((version) => gateway.setTicketSprint(projectId, ref, sprintId, version)), + [run, gateway, projectId, ref], + ); + const remove: TicketDetailViewModel["remove"] = useCallback(async () => { setBusy(true); setError(null); @@ -234,6 +246,7 @@ export function useTicketDetail( link, unlink, assign, + setSprint, remove, }; } diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index ecce05b..f2b4911 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -29,6 +29,8 @@ import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; import { useLiveConnectionState } from "./useLiveConnectionState"; +import { WebTicketsView } from "./tickets/WebTicketsView"; +import { WebSprintsView } from "./tickets/WebSprintsView"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -118,12 +120,109 @@ export function WebWorkspace() { )} {openId && ( - + )} ); } +type ProjectTab = "live" | "tickets" | "sprints"; + +const PROJECT_TABS: { id: ProjectTab; label: string }[] = [ + { id: "live", label: "Live" }, + { id: "tickets", label: "Tickets" }, + { id: "sprints", label: "Sprints" }, +]; + +/** + * Project navigation for the web workspace (ticket #86): `Live` / `Tickets` / + * `Sprints`, a compact tablist above the project surfaces — never the desktop + * dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own + * data (own hook instance), so switching away and back re-fetches fresh rather + * than caching stale state across tabs. + */ +function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) { + const [tab, setTab] = useState("live"); + // Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab. + const [focusSprintId, setFocusSprintId] = useState(null); + + return ( +
+
+ {PROJECT_TABS.map((t) => ( + + ))} +
+ + + + +
+ ); +} + /** * Global reconnection banner (F6). The F3 terminal path writes a "déconnecté" * notice into xterm, but live-only surfaces (no terminal open) had no visible diff --git a/frontend/src/features/web/tickets/WebConfirmDialog.tsx b/frontend/src/features/web/tickets/WebConfirmDialog.tsx new file mode 100644 index 0000000..70aa2d4 --- /dev/null +++ b/frontend/src/features/web/tickets/WebConfirmDialog.tsx @@ -0,0 +1,78 @@ +/** + * Destructive-action confirmation for the web tickets/sprints surface (ticket + * #86). Local to this feature, like `AppExitConfirmDialog` and + * `features/devices/ConfirmDialog` are local to theirs — a shared dialog + * primitive is a design-system decision outside this lot. + * + * `role="alertdialog"`, focus on `Annuler` by default, `Échap` cancels, no + * dismiss-on-outside-click (a destructive confirmation should not be dismissed + * ambiguously), per carnet #86's accessibility section. + */ + +import { useEffect, useRef } from "react"; + +import { Button, zIndex } from "@/shared"; + +export interface WebConfirmDialogProps { + title: string; + body: string; + confirmLabel: string; + busy?: boolean; + onConfirm: () => void | Promise; + onCancel: () => void; +} + +export function WebConfirmDialog({ + title, + body, + confirmLabel, + busy = false, + onConfirm, + onCancel, +}: WebConfirmDialogProps) { + const cancelRef = useRef(null); + + useEffect(() => { + cancelRef.current?.focus(); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl" + > +

{title}

+

{body}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/web/tickets/WebSprintsView.tsx b/frontend/src/features/web/tickets/WebSprintsView.tsx new file mode 100644 index 0000000..e70bb89 --- /dev/null +++ b/frontend/src/features/web/tickets/WebSprintsView.tsx @@ -0,0 +1,311 @@ +/** + * "Sprints" tab of the web workspace (ticket #86): create, rename, reorder + * (Monter/Descendre — no drag-and-drop required) and delete sprints, plus + * add/remove tickets via the mobile-adapted ticket picker. A dedicated surface + * — not an overlay above the ticket list like the desktop `SprintManager` — + * reachable directly from the project tab bar. + * + * Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which + * tickets belong to a sprint client-side, exactly like the desktop + * `SprintManager` — `ticket_list` has no server-side `sprintId` filter). The + * per-sprint ticket list also lets the user remove a ticket from the sprint + * (`assignSprint(ref, null)`, which routes to `ticket_unassign_sprint`) — the + * symmetric counterpart of "Ajouter tickets" (QA #86 fix). + */ + +import { useState } from "react"; + +import type { Sprint, TicketSummary } from "@/domain"; +import { useTickets, useTicketSearch, type TicketsViewModel } from "@/features/tickets"; +import { Button, Input, Panel, Spinner } from "@/shared"; +import { WebConfirmDialog } from "./WebConfirmDialog"; +import { WebTicketPickerSheet } from "./WebTicketPickerSheet"; + +export interface WebSprintsViewProps { + projectId: string; + /** Switches to the Tickets tab, optionally pre-filtered to one sprint. */ + onViewSprintTickets: (sprintId: string) => void; +} + +export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) { + const vm = useTickets(projectId); + // Independent of the Tickets tab's own query (mirrors desktop SprintManager): + // used to resolve which refs already belong to a sprint, both for the + // compact per-sprint ticket list and the "ajouter des tickets" exclude set. + const search = useTicketSearch(projectId, { refreshOnEvents: true }); + + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [renamingId, setRenamingId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null); + const [pickerSprint, setPickerSprint] = useState<{ id: string; name: string } | null>(null); + + const sprints = [...vm.sprints].sort((a, b) => a.order - b.order); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name) return; + const created = await vm.createSprint(name); + if (created) { + setNewName(""); + setShowCreate(false); + } + } + + return ( + setShowCreate((v) => !v)}> + + Sprint + + } + > + {vm.error && ( +

+ {vm.error} +

+ )} + + {showCreate && ( +
+ +
+ + +
+
+ )} + + {vm.busy && vm.sprints.length === 0 ? ( + + Chargement des sprints… + + ) : sprints.length === 0 ? ( +
+

Aucun sprint.

+ +
+ ) : ( +
    + {sprints.map((sprint, index) => ( + t.sprintId === sprint.id)} + vm={vm} + renaming={renamingId === sprint.id} + renameDraft={renameDraft} + onStartRename={() => { + setRenamingId(sprint.id); + setRenameDraft(sprint.name); + }} + onRenameDraftChange={setRenameDraft} + onCancelRename={() => setRenamingId(null)} + onSaveRename={async () => { + const ok = await vm.renameSprint(sprint.id, renameDraft.trim()); + if (ok) setRenamingId(null); + }} + onViewTickets={() => onViewSprintTickets(sprint.id)} + onAddTickets={() => setPickerSprint({ id: sprint.id, name: sprint.name })} + onRequestDelete={() => setConfirmDelete({ id: sprint.id, name: sprint.name })} + /> + ))} +
+ )} + + {confirmDelete && ( + setConfirmDelete(null)} + onConfirm={async () => { + await vm.deleteSprint(confirmDelete.id); + setConfirmDelete(null); + }} + /> + )} + + {pickerSprint && ( + t.sprintId === pickerSprint.id).map((t) => t.ref)} + onSelect={(result) => { + const picked = Array.isArray(result) ? result : [result]; + for (const p of picked) void vm.assignSprint(p.ref, pickerSprint.id); + }} + onClose={() => setPickerSprint(null)} + /> + )} +
+ ); +} + +interface SprintCardProps { + sprint: Sprint; + index: number; + lastIndex: number; + tickets: TicketSummary[]; + vm: TicketsViewModel; + renaming: boolean; + renameDraft: string; + onStartRename: () => void; + onRenameDraftChange: (value: string) => void; + onCancelRename: () => void; + onSaveRename: () => Promise; + onViewTickets: () => void; + onAddTickets: () => void; + onRequestDelete: () => void; +} + +function SprintCard({ + sprint, + index, + lastIndex, + tickets, + vm, + renaming, + renameDraft, + onStartRename, + onRenameDraftChange, + onCancelRename, + onSaveRename, + onViewTickets, + onAddTickets, + onRequestDelete, +}: SprintCardProps) { + return ( +
  • +
    + #{sprint.order} + {!renaming && ( + + {sprint.name} + + )} +
    + + {renaming ? ( +
    + onRenameDraftChange(e.target.value)} + disabled={vm.busy} + /> +
    + + +
    +
    + ) : ( + <> +

    + {sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""} +

    + + {/* Compact ticket list + per-row removal (QA #86 fix): symmetric with + "Ajouter tickets" below. */} + {tickets.length > 0 && ( +
      + {tickets.map((t) => ( +
    • + + {t.ref} + + {t.title} + +
    • + ))} +
    + )} + +
    + + + + + + +
    + + )} +
  • + ); +} diff --git a/frontend/src/features/web/tickets/WebTicketCreate.tsx b/frontend/src/features/web/tickets/WebTicketCreate.tsx new file mode 100644 index 0000000..107cedc --- /dev/null +++ b/frontend/src/features/web/tickets/WebTicketCreate.tsx @@ -0,0 +1,141 @@ +/** + * Ticket creation screen for the web workspace (ticket #86) — replaces the list + * in the column (no inline permanent form, no desktop popup), per carnet #86. + * + * Short form: Titre (required) / Priorité / Sprint / Description (optional). + * On success the caller opens the created ticket's detail so the user can + * complete carnet/assignments/links right away. + */ + +import { useState } from "react"; + +import type { CreateTicketInput } from "@/ports"; +import type { Sprint, TicketPriority } from "@/domain"; +import { Button, Input, Panel, cn } from "@/shared"; +import { TICKET_PRIORITIES } from "@/features/tickets"; +import { webPriorityLabel } from "./webTicketLabels"; + +const selectClass = cn( + "h-9 rounded-md bg-raised px-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +const textareaClass = cn( + "w-full rounded-md bg-raised p-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +export interface WebTicketCreateProps { + sprints: Sprint[]; + busy: boolean; + error: string | null; + onCancel: () => void; + onCreate: (input: CreateTicketInput, sprintId: string | null) => Promise; +} + +export function WebTicketCreate({ sprints, busy, error, onCancel, onCreate }: WebTicketCreateProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [sprintId, setSprintId] = useState(""); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = title.trim(); + if (!trimmed) return; + await onCreate( + { + title: trimmed, + priority, + ...(description.trim() ? { description: description.trim() } : {}), + }, + sprintId || null, + ); + } + + return ( + + Annuler + + } + > + {error && ( +

    + {error} +

    + )} +
    + + + + + + +