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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user