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:
@@ -103,6 +103,10 @@ export default function OdinChat() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const threadRef = useRef<HTMLDivElement>(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.
|
||||
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). */}
|
||||
<Box
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
sx={{
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
@@ -557,11 +612,14 @@ export default function OdinChat() {
|
||||
{review.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: "35vh",
|
||||
maxHeight: "40vh",
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
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) => (
|
||||
@@ -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 && (
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user