feat(tickets): surface frontend V1 du système de tickets

Surface UI complète des tickets (domaine Issue exposé « ticket »),
validée QA de bout en bout : cargo build + tests backend verts,
npm run build + npm test (459) verts.

- Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent.
- Ports : TicketGateway (+ types query/input) ajouté à Gateways.
- Adapters : TauriTicketGateway réel (+ isTicketVersionConflict),
  wiring, et MockTicketGateway pour les tests.
- Feature tickets : hooks (useTickets, useTicketDetail,
  useProjectAgents), TicketsPanel, TicketDetail, TicketsView,
  ticketMeta + tests.
- ProjectsView : onglet sidebar « Tickets ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:56:36 +02:00
parent 8de7be01a8
commit c1d82eee8d
16 changed files with 2150 additions and 1 deletions

View File

@ -0,0 +1,450 @@
/**
* Ticket detail / edit overlay (F3, F4, F5, F7).
*
* A full-screen dialog mirroring the ticket: editable title/description, status
* and priority, the ticket-scoped Markdown **carnet** (replaceable save, F4),
* links to other tickets (F5) and agent assignments limited to known agents
* (F5). Optimistic-concurrency conflicts surface a clear banner and reload
* (F3). `#ref`s are clickable throughout (F7), and a one-click "delegation
* context" prefill lets the user hand an agent "work on #N" (F7).
*/
import { useEffect, useState } from "react";
import type { TicketLinkKind } from "@/domain";
import { Button, Input, Spinner, cn } from "@/shared";
import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents";
import {
PriorityBadge,
StatusBadge,
TICKET_LINK_KINDS,
TICKET_PRIORITIES,
TICKET_STATUSES,
TicketRef,
linkKindLabel,
linkifyTicketRefs,
priorityLabel,
statusLabel,
} from "./ticketMeta";
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 TicketDetailProps {
projectId: string;
ticketRef: string;
onClose: () => void;
/** Navigate to another ticket (from a link or an inline `#ref`, F7). */
onOpenRef: (ref: string) => void;
}
async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
return false;
}
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-2 border-b border-border px-5 py-4">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
{title}
</h4>
{hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
</div>
{children}
</section>
);
}
export function TicketDetail({
projectId,
ticketRef,
onClose,
onOpenRef,
}: TicketDetailProps) {
const vm = useTicketDetail(projectId, ticketRef);
const { agents, nameOf } = useProjectAgents(projectId);
const t = vm.ticket;
// Local draft state, re-seeded whenever the loaded ticket version changes so a
// conflict-reload or an external edit refreshes the fields.
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState("");
const [linkTarget, setLinkTarget] = useState("");
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState("");
const [copied, setCopied] = useState(false);
const version = t?.version;
useEffect(() => {
if (!t) return;
setTitle(t.title);
setDescription(t.description);
setCarnet(t.carnet ?? "");
}, [t, version]);
const dirtyFields =
!!t && (title !== t.title || description !== t.description);
const dirtyCarnet = !!t && carnet !== (t.carnet ?? "");
const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id));
async function copyDelegation() {
if (!t) return;
const ok = await copyToClipboard(
`Travaille sur le ticket ${t.ref} : ${t.title}`,
);
if (ok) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={`ticket ${ticketRef}`}
>
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<TicketRef ticketRef={ticketRef} />
{t && <StatusBadge status={t.status} />}
{t && <PriorityBadge priority={t.priority} />}
<span className="truncate text-sm font-medium text-content">
{t?.title ?? ticketRef}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={!t}
onClick={() => void copyDelegation()}
title="Copy a delegation prompt to hand this ticket to an agent"
>
{copied ? "Copied" : "Delegation context"}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</header>
{vm.conflict && (
<p
role="alert"
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
>
This ticket was modified elsewhere and reloaded. Re-apply your change.
</p>
)}
{vm.error && !vm.conflict && (
<p
role="alert"
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && !t ? (
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
<Spinner size={14} />
<span>Loading ticket</span>
</div>
) : !t ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Ticket not found.
</div>
) : (
<div className="flex-1 overflow-auto">
{/* ── Fields (F3) ── */}
<Section title="Details">
<label className="text-xs font-medium text-muted" htmlFor="td-title">
Title
</label>
<Input
id="td-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={vm.busy}
/>
<label
className="mt-2 text-xs font-medium text-muted"
htmlFor="td-desc"
>
Description
</label>
<textarea
id="td-desc"
className={cn(textareaClass, "min-h-24")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={vm.busy}
/>
{description.trim() && (
<p className="whitespace-pre-wrap break-words rounded-md bg-raised/40 px-3 py-2 text-xs text-muted">
{linkifyTicketRefs(description, onOpenRef)}
</p>
)}
<div className="mt-1 flex items-center gap-2">
<Button
size="sm"
disabled={!dirtyFields || vm.busy}
loading={vm.busy && dirtyFields}
onClick={() =>
void vm.updateFields({ title, description })
}
>
Save changes
</Button>
{dirtyFields && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => {
setTitle(t.title);
setDescription(t.description);
}}
>
Reset
</Button>
)}
</div>
</Section>
{/* ── Status & priority (F3) ── */}
<Section title="Status & priority">
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted">
Status
<select
aria-label="ticket status"
className={selectClass}
value={t.status}
disabled={vm.busy}
onChange={(e) =>
void vm.updateFields({
status: e.target.value as (typeof TICKET_STATUSES)[number],
})
}
>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-xs text-muted">
Priority
<select
aria-label="ticket priority"
className={selectClass}
value={t.priority}
disabled={vm.busy}
onChange={(e) =>
void vm.updateFields({
priority:
e.target.value as (typeof TICKET_PRIORITIES)[number],
})
}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
</label>
</div>
</Section>
{/* ── Carnet (F4) ── */}
<Section
title="Carnet"
hint="Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole carnet."
>
<textarea
aria-label="ticket carnet"
className={cn(textareaClass, "min-h-40 font-mono")}
value={carnet}
onChange={(e) => setCarnet(e.target.value)}
disabled={vm.busy}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!dirtyCarnet || vm.busy}
loading={vm.busy && dirtyCarnet}
onClick={() => void vm.saveCarnet(carnet)}
>
Save carnet
</Button>
{dirtyCarnet && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => setCarnet(t.carnet ?? "")}
>
Reset
</Button>
)}
</div>
</Section>
{/* ── Links (F5) ── */}
<Section title="Linked tickets">
{t.links.length === 0 ? (
<p className="text-xs text-muted">No linked tickets.</p>
) : (
<ul className="flex flex-col gap-1">
{t.links.map((l) => (
<li
key={`${l.kind}-${l.targetRef}`}
className="flex items-center gap-2 text-sm"
>
<span className="text-xs text-muted">
{linkKindLabel(l.kind)}
</span>
<TicketRef ticketRef={l.targetRef} onOpen={onOpenRef} />
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.unlink(l.targetRef, l.kind)}
>
Remove
</Button>
</li>
))}
</ul>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<select
aria-label="link kind"
className={selectClass}
value={linkKind}
disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
>
{TICKET_LINK_KINDS.map((k) => (
<option key={k} value={k}>
{linkKindLabel(k)}
</option>
))}
</select>
<Input
aria-label="link target ref"
placeholder="#id"
className="w-24"
value={linkTarget}
onChange={(e) => setLinkTarget(e.target.value)}
/>
<Button
size="sm"
disabled={!linkTarget.trim() || vm.busy}
onClick={async () => {
const target = linkTarget.trim().startsWith("#")
? linkTarget.trim()
: `#${linkTarget.trim()}`;
const ok = await vm.link(target, linkKind);
if (ok) setLinkTarget("");
}}
>
Add link
</Button>
</div>
</Section>
{/* ── Assignments (F5) ── */}
<Section
title="Assigned agents"
hint="Only agents known to this project can be assigned."
>
{t.assignedAgentIds.length === 0 ? (
<p className="text-xs text-muted">No agents assigned.</p>
) : (
<ul className="flex flex-wrap gap-2">
{t.assignedAgentIds.map((id) => (
<li
key={id}
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
>
{nameOf(id)}
<button
type="button"
aria-label={`unassign ${nameOf(id)}`}
className="text-muted hover:text-danger disabled:opacity-50"
disabled={vm.busy}
onClick={() => void vm.assign(id, false)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="mt-1 flex items-center gap-2">
<select
aria-label="assign agent"
className={selectClass}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)}
>
<option value="">
{assignable.length === 0
? "No more agents"
: "Select an agent…"}
</option>
{assignable.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button
size="sm"
disabled={!assignPick || vm.busy}
onClick={async () => {
const ok = await vm.assign(assignPick, true);
if (ok) setAssignPick("");
}}
>
Assign
</Button>
</div>
</Section>
</div>
)}
</div>
);
}