diff --git a/src/admin/components/odin/InvoiceReviewCard.tsx b/src/admin/components/odin/InvoiceReviewCard.tsx new file mode 100644 index 0000000..9f53f5e --- /dev/null +++ b/src/admin/components/odin/InvoiceReviewCard.tsx @@ -0,0 +1,113 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Chip from "@mui/material/Chip"; +import { Card, Button, TextField } from "../../ui"; +import type { ReviewInvoice, EditableField } from "./types"; + +interface InvoiceReviewCardProps { + inv: ReviewInvoice; + busy: boolean; + onChange: (uid: string, field: EditableField, value: string) => void; + onSave: (inv: ReviewInvoice) => void; + onDiscard: (uid: string) => void; +} + +const fieldCols = { xs: "1fr", sm: "1fr 1fr" }; + +export default function InvoiceReviewCard({ + inv, + busy, + onChange, + onSave, + onDiscard, +}: InvoiceReviewCardProps) { + return ( + + {/* Header: chip + filename */} + + + + {inv.file_name} + + + + {/* 2-column field grid */} + + onChange(inv.uid, "supplier_name", e.target.value)} + /> + onChange(inv.uid, "invoice_number", e.target.value)} + /> + onChange(inv.uid, "amount", e.target.value)} + /> + onChange(inv.uid, "currency", e.target.value)} + /> + onChange(inv.uid, "vat_rate", e.target.value)} + /> + onChange(inv.uid, "issue_date", e.target.value)} + /> + onChange(inv.uid, "due_date", e.target.value)} + /> + onChange(inv.uid, "description", e.target.value)} + /> + + + {/* Actions */} + + + + + + ); +} diff --git a/src/admin/components/odin/OdinComposer.tsx b/src/admin/components/odin/OdinComposer.tsx new file mode 100644 index 0000000..317760c --- /dev/null +++ b/src/admin/components/odin/OdinComposer.tsx @@ -0,0 +1,82 @@ +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import { Button, TextField } from "../../ui"; +import type { StagedFile } from "./types"; + +interface OdinComposerProps { + input: string; + attachments: StagedFile[]; + busy: boolean; + canSubmit: boolean; + fileRef: React.RefObject; + onInput: (v: string) => void; + onFiles: (files: FileList | null) => void; + onRemoveAttachment: (id: string) => void; + onSubmit: () => void; +} + +export default function OdinComposer({ + input, + attachments, + busy, + canSubmit, + fileRef, + onInput, + onFiles, + onRemoveAttachment, + onSubmit, +}: OdinComposerProps) { + return ( + + {/* Staged attachment chips */} + {attachments.length > 0 && ( + + {attachments.map((s) => ( + onRemoveAttachment(s.id)} + /> + ))} + + )} + + {/* Composer row */} + + } + type="file" + accept="application/pdf,image/*" + multiple + hidden + onChange={(e) => onFiles(e.target.files)} + /> + + onInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }} + /> + + + + ); +} diff --git a/src/admin/components/odin/OdinTabs.tsx b/src/admin/components/odin/OdinTabs.tsx new file mode 100644 index 0000000..327db05 --- /dev/null +++ b/src/admin/components/odin/OdinTabs.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import IconButton from "@mui/material/IconButton"; +import { Button, TextField, Modal, ConfirmDialog } from "../../ui"; + +interface OdinTabsProps { + conversations: { id: number; title: string }[]; + activeId: number | null; + busy: boolean; + onSelect: (id: number) => void; + onNew: () => void; + onRename: (id: number, title: string) => void; + onDelete: (id: number) => void; +} + +export default function OdinTabs({ + conversations, + activeId, + busy, + onSelect, + onNew, + onRename, + onDelete, +}: OdinTabsProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [renameOpen, setRenameOpen] = useState(false); + const [renameDraft, setRenameDraft] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); + + const activeConv = conversations.find((c) => c.id === activeId) ?? null; + + const openMenu = (e: React.MouseEvent) => { + e.stopPropagation(); + setAnchorEl(e.currentTarget); + }; + const closeMenu = () => setAnchorEl(null); + + const handleRenameOpen = () => { + closeMenu(); + setRenameDraft(activeConv?.title ?? ""); + setRenameOpen(true); + }; + + const handleRenameSubmit = () => { + if (activeId !== null) { + onRename(activeId, renameDraft.trim() || (activeConv?.title ?? "")); + } + setRenameOpen(false); + }; + + const handleDeleteOpen = () => { + closeMenu(); + setDeleteOpen(true); + }; + + const handleDeleteConfirm = () => { + if (activeId !== null) { + onDelete(activeId); + } + setDeleteOpen(false); + }; + + return ( + <> + + {conversations.map((conv) => { + const isActive = conv.id === activeId; + return ( + + + {isActive && ( + + ⋮ + + )} + + ); + })} + + + + + {/* Overflow menu */} + + Přejmenovat + Smazat + + + {/* Rename modal */} + setRenameOpen(false)} + onSubmit={handleRenameSubmit} + title="Přejmenovat konverzaci" + submitText="Přejmenovat" + maxWidth="xs" + > + + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleRenameSubmit(); + } + }} + autoFocus + /> + + + + {/* Delete confirm */} + setDeleteOpen(false)} + onConfirm={handleDeleteConfirm} + title="Smazat konverzaci" + message={`Opravdu chcete smazat konverzaci „${activeConv?.title ?? ""}"? Tuto akci nelze vrátit.`} + confirmText="Smazat" + confirmVariant="danger" + /> + + ); +} diff --git a/src/admin/components/odin/OdinThread.tsx b/src/admin/components/odin/OdinThread.tsx new file mode 100644 index 0000000..d80a2ef --- /dev/null +++ b/src/admin/components/odin/OdinThread.tsx @@ -0,0 +1,158 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import CircularProgress from "@mui/material/CircularProgress"; +import type { ChatTurn } from "./types"; + +interface OdinThreadProps { + turns: ChatTurn[]; + busy: boolean; + loading: boolean; + threadRef: React.RefObject; +} + +/** Small Odin avatar shown to the left of assistant bubbles. */ +function OdinAvatar({ size = 28 }: { size?: number }) { + return ( + + O + + ); +} + +export default function OdinThread({ + turns, + busy, + loading, + threadRef, +}: OdinThreadProps) { + return ( + + {/* Loading state — history is being fetched */} + {loading && ( + + + Načítám… + + + )} + + {/* Empty state */} + {turns.length === 0 && !busy && !loading && ( + + + + Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k + importu. + + + )} + + {/* Message bubbles */} + {turns.map((t, i) => { + const isUser = t.role === "user"; + return ( + + {!isUser && } + + {/* Color MUST sit on the Typography: GlobalStyles pins `p` to + text.secondary, which beats a color merely inherited from the + Box. An sx class on the element wins over that element rule. */} + + {t.content} + + + + ); + })} + + {/* Busy indicator */} + {busy && ( + + + + Pracuji… + + + )} + + ); +} diff --git a/src/admin/components/odin/types.ts b/src/admin/components/odin/types.ts new file mode 100644 index 0000000..02f5d6f --- /dev/null +++ b/src/admin/components/odin/types.ts @@ -0,0 +1,33 @@ +export interface ChatTurn { + role: "user" | "assistant"; + content: string; +} + +export interface ReviewInvoice { + uid: string; + supplier_name: string; + invoice_number: string; + amount: string; + currency: string; + vat_rate: string; + issue_date: string; + due_date: string; + description: string; + file_name: string; + file: File; +} + +export type EditableField = + | "supplier_name" + | "invoice_number" + | "amount" + | "currency" + | "vat_rate" + | "issue_date" + | "due_date" + | "description"; + +export interface StagedFile { + id: string; + file: File; +}