Files
IdeA/frontend/src/features/tickets/TicketDetail.tsx
Blomios c1d82eee8d 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>
2026-07-02 22:56:36 +02:00

451 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}