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>
This commit is contained in:
@ -12,10 +12,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { TicketLinkKind } from "@/domain";
|
||||
import { Button, Input, Spinner, cn } from "@/shared";
|
||||
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,
|
||||
@ -98,7 +99,6 @@ export function TicketDetail({
|
||||
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);
|
||||
@ -106,6 +106,11 @@ export function TicketDetail({
|
||||
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).
|
||||
@ -187,14 +192,14 @@ export function TicketDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`ticket ${ticketRef}`}
|
||||
<FloatingWindow
|
||||
open
|
||||
title={`ticket ${ticketRef}`}
|
||||
size="xl"
|
||||
onClose={requestClose}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||
{/* ── 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} />}
|
||||
@ -223,16 +228,13 @@ export function TicketDetail({
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={requestClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{vm.conflict && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
|
||||
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>
|
||||
@ -240,23 +242,23 @@ export function TicketDetail({
|
||||
{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"
|
||||
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 flex-1 items-center gap-2 p-5 text-sm text-muted">
|
||||
<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 flex-1 items-center justify-center text-sm text-muted">
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted">
|
||||
Ticket not found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="-mx-4">
|
||||
{/* ── Fields (F3) ── */}
|
||||
<Section title="Details">
|
||||
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||
@ -421,6 +423,8 @@ export function TicketDetail({
|
||||
))}
|
||||
</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"
|
||||
@ -435,23 +439,11 @@ export function TicketDetail({
|
||||
</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("");
|
||||
}}
|
||||
aria-label="add link"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setShowLinkPicker(true)}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
@ -518,16 +510,48 @@ export function TicketDetail({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── AI assistant chat (#8, F2) ── */}
|
||||
<Section
|
||||
title="Assistant IA"
|
||||
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket."
|
||||
>
|
||||
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||
</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
|
||||
@ -623,6 +647,6 @@ export function TicketDetail({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user