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 */}
+
+
+ {/* 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;
+}