From 0172c9a44289886f6a7d53724f3a81dd0e280d9b Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 4 Jul 2026 03:22:26 +0200 Subject: [PATCH] feat(odin): multi-invoice review scrolls; drag & drop attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Review panel: flex children no longer shrink (flexShrink 0) — with 2+ extracted invoices the cards kept compressing to fit 35vh, squashing the field grids with nothing to scroll; panel now scrolls, maxHeight 40vh. - Drag & drop onto the chat column: depth-counted dragenter/leave (no flicker), Files-only, refused while busy, PDF/image filter with a Czech hint toast when nothing usable was dropped, channel-alpha overlay with dashed primary border ('Přetáhněte soubory sem'), staged through the same path as the paperclip. Co-Authored-By: Claude Fable 5 --- src/admin/components/odin/OdinChat.tsx | 125 ++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/src/admin/components/odin/OdinChat.tsx b/src/admin/components/odin/OdinChat.tsx index 1850d59..91f3af2 100644 --- a/src/admin/components/odin/OdinChat.tsx +++ b/src/admin/components/odin/OdinChat.tsx @@ -103,6 +103,10 @@ export default function OdinChat() { const fileRef = useRef(null); const threadRef = useRef(null); const seededId = useRef(null); + // Drag & drop over the chat column. The depth counter pairs enter/leave + // events bubbling from children so the overlay doesn't flicker mid-drag. + const [dragOver, setDragOver] = useState(false); + const dragDepth = useRef(0); // Below md the conversation list collapses into a slide-in drawer. const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), { @@ -177,17 +181,63 @@ export default function OdinChat() { }; // Attaching only stages files; nothing is sent until Odeslat. - const onFiles = (files: FileList | null) => { - if (!files || files.length === 0) return; + const stageFiles = (files: File[]) => { + if (files.length === 0) return; setAttachments((a) => [ ...a, - ...Array.from(files).map((file) => ({ id: nextUid(), file })), + ...files.map((file) => ({ id: nextUid(), file })), ]); + }; + const onFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + stageFiles(Array.from(files)); if (fileRef.current) fileRef.current.value = ""; }; const removeAttachment = (id: string) => setAttachments((a) => a.filter((s) => s.id !== id)); + // Drag & drop anywhere over the chat column stages files exactly like the + // paperclip (same accept filter as the file input). Only file drags react — + // text/link drags pass through untouched — and nothing stages while busy. + const isAccepted = (f: File) => + f.type === "application/pdf" || f.type.startsWith("image/"); + const dragHasFiles = (e: React.DragEvent) => + Array.from(e.dataTransfer.types).includes("Files"); + + const onDragEnter = (e: React.DragEvent) => { + if (busy || !dragHasFiles(e)) return; + e.preventDefault(); + dragDepth.current += 1; + setDragOver(true); + }; + const onDragOver = (e: React.DragEvent) => { + if (!dragHasFiles(e)) return; + // preventDefault permits the drop (and keeps the browser from replacing + // the app with the dropped file); while busy the drop itself is refused. + e.preventDefault(); + if (busy) e.dataTransfer.dropEffect = "none"; + }; + const onDragLeave = () => { + if (dragDepth.current === 0) return; + dragDepth.current -= 1; + if (dragDepth.current === 0) setDragOver(false); + }; + const onDrop = (e: React.DragEvent) => { + if (!dragHasFiles(e)) return; + e.preventDefault(); + dragDepth.current = 0; + setDragOver(false); + if (busy) return; + const dropped = Array.from(e.dataTransfer.files); + const accepted = dropped.filter(isAccepted); + if (accepted.length === 0) { + // Nothing usable — hint instead of silently doing nothing. + if (dropped.length > 0) alert.info("Podporované soubory: PDF a obrázky"); + return; + } + stageFiles(accepted); + }; + const onSelect = (id: number) => { setSidebarOpen(false); if (id === activeId || busy) return; @@ -467,9 +517,14 @@ export default function OdinChat() { sidebar )} - {/* Chat column */} + {/* Chat column — also the drop target for invoice files (overlay below). */} 0 && ( *": { flexShrink: 0 }, }} > {review.map((inv) => ( @@ -591,6 +649,63 @@ export default function OdinChat() { onRemoveAttachment={removeAttachment} onSubmit={submit} /> + + {/* Drop-target overlay — pointer-events pass through so the drag + events keep firing on the column beneath it. */} + {dragOver && ( + ({ + position: "absolute", + inset: 0, + zIndex: 10, + pointerEvents: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", + // Longhand on purpose — see the container border note above. + borderStyle: "dashed", + borderWidth: 2, + borderColor: "primary.main", + borderRadius: { xs: 0, md: 3 }, + bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.08)`, + })} + > + + + + + + + + Přetáhněte soubory sem + + + PDF nebo obrázky faktur + + + + )} );