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>
653 lines
23 KiB
TypeScript
653 lines
23 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, 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 ?
|
||
</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 {ticketRef} ? 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>
|
||
);
|
||
}
|