feat(odin): multi-invoice review scrolls; drag & drop attachments

- 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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-04 03:22:26 +02:00
parent 81b4cb51e7
commit 0172c9a442

View File

@@ -103,6 +103,10 @@ export default function OdinChat() {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const threadRef = useRef<HTMLDivElement>(null); const threadRef = useRef<HTMLDivElement>(null);
const seededId = useRef<number | null>(null); const seededId = useRef<number | null>(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. // Below md the conversation list collapses into a slide-in drawer.
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), { 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. // Attaching only stages files; nothing is sent until Odeslat.
const onFiles = (files: FileList | null) => { const stageFiles = (files: File[]) => {
if (!files || files.length === 0) return; if (files.length === 0) return;
setAttachments((a) => [ setAttachments((a) => [
...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 = ""; if (fileRef.current) fileRef.current.value = "";
}; };
const removeAttachment = (id: string) => const removeAttachment = (id: string) =>
setAttachments((a) => a.filter((s) => s.id !== id)); 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) => { const onSelect = (id: number) => {
setSidebarOpen(false); setSidebarOpen(false);
if (id === activeId || busy) return; if (id === activeId || busy) return;
@@ -467,9 +517,14 @@ export default function OdinChat() {
sidebar sidebar
)} )}
{/* Chat column */} {/* Chat column — also the drop target for invoice files (overlay below). */}
<Box <Box
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
sx={{ sx={{
position: "relative",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
display: "flex", display: "flex",
@@ -557,11 +612,14 @@ export default function OdinChat() {
{review.length > 0 && ( {review.length > 0 && (
<Box <Box
sx={{ sx={{
maxHeight: "35vh", maxHeight: "40vh",
overflowY: "auto", overflowY: "auto",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 1, gap: 1,
// Flex children shrink by default — with 2+ cards they'd get
// compressed to fit instead of overflowing into the scrollbar.
"& > *": { flexShrink: 0 },
}} }}
> >
{review.map((inv) => ( {review.map((inv) => (
@@ -591,6 +649,63 @@ export default function OdinChat() {
onRemoveAttachment={removeAttachment} onRemoveAttachment={removeAttachment}
onSubmit={submit} onSubmit={submit}
/> />
{/* Drop-target overlay — pointer-events pass through so the drag
events keep firing on the column beneath it. */}
{dragOver && (
<Box
sx={(t) => ({
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)`,
})}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0.5,
px: 3,
py: 2,
borderRadius: 2,
bgcolor: "background.paper",
boxShadow: 3,
}}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 32, height: 32, color: "primary.main", mb: 0.5 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Přetáhněte soubory sem
</Typography>
<Typography variant="caption" color="text.secondary">
PDF nebo obrázky faktur
</Typography>
</Box>
</Box>
)}
</Box> </Box>
</Box> </Box>
); );