Files
IdeA/frontend/src/features/tickets/TicketDetail.tsx
Blomios 7caced1b99 feat(tickets): fenêtre dédiée de gestion des tickets — liens via popup + assistant IA flottant (#17)
Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets,
appuyée sur la primitive FloatingWindow (#16) et la popup TicketPicker (#18).

- TicketDetail : gestion des liens de ticket via la popup TicketPicker,
  assistant IA en fenêtre flottante
- FloatingWindow : ajustements pour l'usage fenêtre dédiée

Tests : tsc --noEmit clean, suite complète 530/530, suites tickets +
FloatingWindow 50/50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 06:35:20 +02:00

653 lines
23 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, useRef, useState } from "react";
import type { TicketLinkKind } from "@/domain";
import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared";
import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel";
import { TicketPicker } from "./TicketPicker";
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 only on a genuine (re)load of the ticket —
// initial load, an external-event refresh, or a conflict reload — tracked via
// the hook's `reloadCount`. It is deliberately NOT re-seeded on an ordinary
// optimistic mutation (e.g. changing the priority), which bumps the ticket's
// version but must not clobber an in-progress, unsaved title/description edit
// (#9).
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState("");
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState("");
const [copied, setCopied] = useState(false);
// Confirmation shown when the user tries to close with unsaved changes.
const [confirmClose, setConfirmClose] = useState(false);
// Confirmation shown before deleting the ticket (#6).
const [confirmDelete, setConfirmDelete] = useState(false);
// Add-link TicketPicker open state (#17): replaces the manual `#id` input.
const [showLinkPicker, setShowLinkPicker] = useState(false);
// AI writing-assistant window open state (#17): opened from the bottom-right
// floating button so the assistant is an obvious, first-class affordance.
const [showAssistant, setShowAssistant] = useState(false);
// The ticket was deleted (by this view or another actor): close the surface.
// The removal from lists flows from the same `issueDeleted` event (#6).
useEffect(() => {
if (vm.deleted) onClose();
}, [vm.deleted, onClose]);
const reloadCount = vm.reloadCount;
const seededReload = useRef(-1);
// The persisted values the draft was last seeded from. Lets a benign reload
// (echo of one's own priority change, or an external edit to an untouched
// field) adopt fresh values field-by-field WITHOUT clobbering a field the user
// is currently editing (#9).
const baseline = useRef<{
title: string;
description: string;
carnet: string;
} | null>(null);
useEffect(() => {
if (!t) return;
// Re-seed once per (re)load. An optimistic mutation (e.g. a priority change)
// changes `t` without bumping `reloadCount`, so this guard short-circuits and
// preserves the draft (#9).
if (seededReload.current === reloadCount) return;
seededReload.current = reloadCount;
const nextCarnet = t.carnet ?? "";
const base = baseline.current;
// Force a full reset on the initial load and on a conflict reload (the local
// edit is stale — the user must re-apply). A benign reload preserves any
// field the user has edited since the last seed.
const forced = base === null || vm.conflict;
if (forced) {
setTitle(t.title);
setDescription(t.description);
setCarnet(nextCarnet);
} else {
setTitle((cur) => (cur === base.title ? t.title : cur));
setDescription((cur) => (cur === base.description ? t.description : cur));
setCarnet((cur) => (cur === base.carnet ? nextCarnet : cur));
}
baseline.current = {
title: t.title,
description: t.description,
carnet: nextCarnet,
};
}, [t, reloadCount, vm.conflict]);
const dirtyFields =
!!t && (title !== t.title || description !== t.description);
const dirtyCarnet = !!t && carnet !== (t.carnet ?? "");
const dirty = dirtyFields || dirtyCarnet;
const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id));
/** Close guard: prompt before discarding unsaved edits, else close directly. */
function requestClose() {
if (dirty) setConfirmClose(true);
else onClose();
}
/** Save whatever is dirty, then close only if every save succeeded. */
async function saveAndClose() {
let ok = true;
if (dirtyFields) ok = (await vm.updateFields({ title, description })) && ok;
if (ok && dirtyCarnet) ok = (await vm.saveCarnet(carnet)) && ok;
setConfirmClose(false);
// On a failed save (e.g. a version conflict) stay open so the banner shows.
if (ok) onClose();
}
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 (
<FloatingWindow
open
title={`ticket ${ticketRef}`}
size="xl"
onClose={requestClose}
>
{/* ── Toolbar (badges + ticket-level actions) ── */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-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"
disabled={!t || vm.busy}
onClick={() => setConfirmDelete(true)}
className="text-danger hover:text-danger"
title="Supprimer définitivement ce ticket"
>
Supprimer
</Button>
</div>
</div>
{vm.conflict && (
<p
role="alert"
className="border-b border-warning/40 bg-warning/10 px-1 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="border-b border-danger/40 bg-danger/10 px-1 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && !t ? (
<div className="flex items-center gap-2 py-5 text-sm text-muted">
<Spinner size={14} />
<span>Loading ticket</span>
</div>
) : !t ? (
<div className="flex items-center justify-center py-10 text-sm text-muted">
Ticket not found.
</div>
) : (
<div className="-mx-4">
{/* ── 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>
)}
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
here, the target ticket is selected in the popup (no manual #id). */}
<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>
<Button
size="sm"
aria-label="add link"
disabled={vm.busy}
onClick={() => setShowLinkPicker(true)}
>
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>
{/* ── Add-link picker (#17) — nested (token 60) over this window ── */}
<TicketPicker
open={showLinkPicker}
projectId={projectId}
title="Lier un ticket"
selectionMode="single"
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked) void vm.link(picked.ref, linkKind);
setShowLinkPicker(false);
}}
onClose={() => setShowLinkPicker(false)}
/>
</div>
)}
{/* ── Floating "Assistant IA" button (#17), bottom-right of the window.
Made prominent per the explicit user request; opens the ticket-writing
AI assistant in a nested window. Sticks to the window's bottom edge. ── */}
{t && (
<div className="pointer-events-none sticky bottom-0 z-10 -mx-4 -mb-4 flex justify-end px-4 pb-4 pt-2">
<Button
aria-label="ouvrir l'assistant IA"
className="pointer-events-auto shadow-lg"
onClick={() => setShowAssistant(true)}
>
🤖 Assistant IA
</Button>
</div>
)}
{/* AI writing assistant, in a nested window (token 60) above this one. */}
<FloatingWindow
open={showAssistant}
title={`Assistant IA — ${ticketRef}`}
size="lg"
onClose={() => setShowAssistant(false)}
>
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
</FloatingWindow>
{/* ── Unsaved-changes confirmation on close (#9) ── */}
{confirmClose && (
<div
role="dialog"
aria-modal="true"
aria-label="unsaved changes"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Modifications non sauvegardées
</h4>
<p className="mt-2 text-sm text-muted">
Vous avez des modifications non sauvegardées. Voulez-vous les
sauvegarder avant de fermer&nbsp;?
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel close"
disabled={vm.busy}
onClick={() => setConfirmClose(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="ghost"
aria-label="close without saving"
disabled={vm.busy}
onClick={onClose}
className="text-danger hover:text-danger"
>
Fermer sans sauvegarder
</Button>
<Button
size="sm"
aria-label="save and close"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void saveAndClose()}
>
Sauvegarder
</Button>
</div>
</div>
</div>
)}
{/* ── Delete confirmation (#6) ── */}
{confirmDelete && (
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Supprimer le ticket
</h4>
<p className="mt-2 text-sm text-muted">
Supprimer définitivement le ticket&nbsp;{ticketRef}&nbsp;? Cette
action est irréversible.
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel delete"
disabled={vm.busy}
onClick={() => setConfirmDelete(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm delete"
loading={vm.busy}
disabled={vm.busy}
onClick={async () => {
const ok = await vm.remove();
// On success the surface closes via the `issueDeleted` event
// (the `deleted` flag → onClose effect); just drop the dialog.
if (ok) setConfirmDelete(false);
}}
>
Supprimer
</Button>
</div>
</div>
</div>
)}
</FloatingWindow>
);
}