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>
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|