Files
IdeA/frontend/src/features/web/tickets/WebTicketCreate.tsx
Blomios e69361feb7 feat(frontend): tickets/sprints surface for the web workspace (#86 lot 2)
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 <noreply@anthropic.com>
2026-07-21 07:35:20 +02:00

142 lines
4.5 KiB
TypeScript

/**
* 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<void>;
}
export function WebTicketCreate({ sprints, busy, error, onCancel, onCreate }: WebTicketCreateProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TicketPriority>("medium");
const [sprintId, setSprintId] = useState<string>("");
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 (
<Panel
title="Nouveau ticket"
actions={
<Button size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
}
>
{error && (
<p role="alert" className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
{error}
</p>
)}
<form onSubmit={submit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Titre</span>
<Input
aria-label="Titre"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
autoFocus
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Priorité</span>
<select
aria-label="Priorité"
className={selectClass}
value={priority}
disabled={busy}
onChange={(e) => setPriority(e.target.value as TicketPriority)}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Sprint</span>
<select
aria-label="Sprint"
className={selectClass}
value={sprintId}
disabled={busy}
onChange={(e) => setSprintId(e.target.value)}
>
<option value="">Sans sprint</option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Description</span>
<textarea
aria-label="Description"
className={cn(textareaClass, "min-h-20")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={busy}
/>
</label>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={onCancel}>
Annuler
</Button>
<Button type="submit" size="sm" disabled={!title.trim() || busy} loading={busy}>
Créer
</Button>
</div>
</form>
</Panel>
);
}