From e69361feb733e906312981d9b359b30c7822507b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 07:35:20 +0200 Subject: [PATCH] feat(frontend): tickets/sprints surface for the web workspace (#86 lot 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Live/Tickets/Sprints tab navigation to WebWorkspace after a project is opened — a mobile-first adaptation, not a port of the desktop docks/ floating windows, per carnet #86. The web-server transport (17 ticket_*/ sprint_* commands) and the HttpTicketGateway were already wired (lot 1 + pre-existing gateway code); this lot is UI only. - Tickets tab: search/filters, list grouped by sprint, create screen, detail view with stacked accordion sections (Résumé/Statut et priorité/ Carnet open by default; Agents assignés/Liens/Zone dangereuse collapsed), delete confirmation, optimistic-concurrency conflict banner. - Sprints tab: create/rename/reorder (Monter/Descendre)/delete with confirmation, add tickets via a mobile full-screen ticket picker (never a small desktop modal), "Voir tickets" filters the Tickets tab to one sprint. - Reuses the transport-neutral hooks as-is (useTickets, useTicketDetail, useTicketSearch) — only presentation and copy are web-specific, in French per decision #78 (new local label module, not a reuse of the English desktop ticketMeta labels). - Workaround: `list_agents` isn't in the web-server allowlist, so agent names/assignment options are derived from `get_project_work_state` (already used by the Live tab) instead of the desktop's useProjectAgents. Co-Authored-By: Claude Sonnet 5 --- frontend/src/features/tickets/index.ts | 3 + frontend/src/features/web/WebWorkspace.tsx | 101 +++- .../features/web/tickets/WebConfirmDialog.tsx | 78 +++ .../features/web/tickets/WebSprintsView.tsx | 233 +++++++++ .../features/web/tickets/WebTicketCreate.tsx | 141 ++++++ .../features/web/tickets/WebTicketDetail.tsx | 443 ++++++++++++++++++ .../web/tickets/WebTicketPickerSheet.tsx | 207 ++++++++ .../web/tickets/WebTicketsSprints.test.tsx | 263 +++++++++++ .../features/web/tickets/WebTicketsView.tsx | 333 +++++++++++++ .../web/tickets/useWebProjectAgents.ts | 41 ++ .../features/web/tickets/webTicketLabels.tsx | 100 ++++ 11 files changed, 1942 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/web/tickets/WebConfirmDialog.tsx create mode 100644 frontend/src/features/web/tickets/WebSprintsView.tsx create mode 100644 frontend/src/features/web/tickets/WebTicketCreate.tsx create mode 100644 frontend/src/features/web/tickets/WebTicketDetail.tsx create mode 100644 frontend/src/features/web/tickets/WebTicketPickerSheet.tsx create mode 100644 frontend/src/features/web/tickets/WebTicketsSprints.test.tsx create mode 100644 frontend/src/features/web/tickets/WebTicketsView.tsx create mode 100644 frontend/src/features/web/tickets/useWebProjectAgents.ts create mode 100644 frontend/src/features/web/tickets/webTicketLabels.tsx 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/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..948df2d --- /dev/null +++ b/frontend/src/features/web/tickets/WebSprintsView.tsx @@ -0,0 +1,233 @@ +/** + * "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). + */ + +import { useState } from "react"; + +import { useTickets, useTicketSearch } 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 only to resolve which refs already belong to a sprint, for the + // "ajouter des tickets" picker's 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) => { + const renaming = renamingId === sprint.id; + return ( +
  • +
    + #{sprint.order} + {!renaming && ( + + {sprint.name} + + )} +
    + + {renaming ? ( +
    + setRenameDraft(e.target.value)} + disabled={vm.busy} + /> +
    + + +
    +
    + ) : ( + <> +

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

    +
    + + + + + + +
    + + )} +
  • + ); + })} +
+ )} + + {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)} + /> + )} +
+ ); +} 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} +

+ )} +
+ + + + + + +