feat(odin): tabs, thread, review-card, composer components

Add four presentational components + shared types under
src/admin/components/odin/ as props-driven building blocks for the
multi-conversation Odin page (Task 6 wires state/queries).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 19:31:04 +02:00
parent f011a67ff3
commit 441f46a471
5 changed files with 550 additions and 0 deletions

View File

@@ -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 (
<Card variant="outlined" sx={{ p: 1.5 }}>
{/* Header: chip + filename */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Chip label="Faktura" size="small" color="primary" />
<Typography
variant="caption"
color="text.secondary"
noWrap
title={inv.file_name}
>
{inv.file_name}
</Typography>
</Box>
{/* 2-column field grid */}
<Box
sx={{
display: "grid",
gridTemplateColumns: fieldCols,
gap: 1,
}}
>
<TextField
label="Dodavatel"
value={inv.supplier_name}
disabled={busy}
onChange={(e) => onChange(inv.uid, "supplier_name", e.target.value)}
/>
<TextField
label="Číslo faktury"
value={inv.invoice_number}
disabled={busy}
onChange={(e) => onChange(inv.uid, "invoice_number", e.target.value)}
/>
<TextField
label="Částka s DPH"
value={inv.amount}
disabled={busy}
onChange={(e) => onChange(inv.uid, "amount", e.target.value)}
/>
<TextField
label="Měna"
value={inv.currency}
disabled={busy}
onChange={(e) => onChange(inv.uid, "currency", e.target.value)}
/>
<TextField
label="Sazba DPH %"
value={inv.vat_rate}
disabled={busy}
onChange={(e) => onChange(inv.uid, "vat_rate", e.target.value)}
/>
<TextField
label="Datum vystavení"
value={inv.issue_date}
disabled={busy}
onChange={(e) => onChange(inv.uid, "issue_date", e.target.value)}
/>
<TextField
label="Datum splatnosti"
value={inv.due_date}
disabled={busy}
onChange={(e) => onChange(inv.uid, "due_date", e.target.value)}
/>
<TextField
label="Popis"
value={inv.description}
disabled={busy}
onChange={(e) => onChange(inv.uid, "description", e.target.value)}
/>
</Box>
{/* Actions */}
<Box sx={{ display: "flex", gap: 1, mt: 1.5 }}>
<Button onClick={() => onSave(inv)} disabled={busy}>
Uložit
</Button>
<Button
variant="outlined"
color="inherit"
onClick={() => onDiscard(inv.uid)}
disabled={busy}
>
Zahodit
</Button>
</Box>
</Card>
);
}

View File

@@ -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<HTMLInputElement | null>;
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 (
<Box>
{/* Staged attachment chips */}
{attachments.length > 0 && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 1 }}>
{attachments.map((s) => (
<Chip
key={s.id}
label={s.file.name}
size="small"
variant="outlined"
onDelete={busy ? undefined : () => onRemoveAttachment(s.id)}
/>
))}
</Box>
)}
{/* Composer row */}
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<input
ref={fileRef as React.RefObject<HTMLInputElement>}
type="file"
accept="application/pdf,image/*"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
<Button
variant="outlined"
color="inherit"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
Přiložit
</Button>
<TextField
fullWidth
placeholder="Napište zprávu…"
value={input}
onChange={(e) => onInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
/>
<Button onClick={onSubmit} disabled={!canSubmit}>
Odeslat
</Button>
</Box>
</Box>
);
}

View File

@@ -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<HTMLElement | null>(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<HTMLElement>) => {
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 (
<>
<Box
sx={{
display: "flex",
gap: 0.5,
overflowX: "auto",
pb: 0.5,
alignItems: "center",
}}
>
{conversations.map((conv) => {
const isActive = conv.id === activeId;
return (
<Box
key={conv.id}
sx={{ display: "flex", alignItems: "center", flexShrink: 0 }}
>
<Button
size="small"
variant={isActive ? "contained" : "outlined"}
color={isActive ? "primary" : "inherit"}
disabled={busy}
onClick={() => onSelect(conv.id)}
>
{conv.title}
</Button>
{isActive && (
<IconButton
size="small"
disabled={busy}
onClick={openMenu}
aria-label="Možnosti konverzace"
title="Možnosti konverzace"
sx={{ ml: 0.25 }}
>
</IconButton>
)}
</Box>
);
})}
<Button
size="small"
variant="outlined"
color="inherit"
disabled={busy}
onClick={onNew}
sx={{ flexShrink: 0 }}
>
+
</Button>
</Box>
{/* Overflow menu */}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem onClick={handleRenameOpen}>Přejmenovat</MenuItem>
<MenuItem onClick={handleDeleteOpen}>Smazat</MenuItem>
</Menu>
{/* Rename modal */}
<Modal
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
onSubmit={handleRenameSubmit}
title="Přejmenovat konverzaci"
submitText="Přejmenovat"
maxWidth="xs"
>
<Box sx={{ pt: 1 }}>
<TextField
label="Název"
fullWidth
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameSubmit();
}
}}
autoFocus
/>
</Box>
</Modal>
{/* Delete confirm */}
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDeleteConfirm}
title="Smazat konverzaci"
message={`Opravdu chcete smazat konverzaci „${activeConv?.title ?? ""}"? Tuto akci nelze vrátit.`}
confirmText="Smazat"
confirmVariant="danger"
/>
</>
);
}

View File

@@ -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<HTMLDivElement | null>;
}
/** Small Odin avatar shown to the left of assistant bubbles. */
function OdinAvatar({ size = 28 }: { size?: number }) {
return (
<Box
sx={{
width: size,
height: size,
borderRadius: "50%",
bgcolor: "primary.main",
color: "common.white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: size * 0.5,
flexShrink: 0,
}}
>
O
</Box>
);
}
export default function OdinThread({
turns,
busy,
loading,
threadRef,
}: OdinThreadProps) {
return (
<Box
ref={threadRef}
sx={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 1.5,
p: 2,
bgcolor: "action.hover",
borderRadius: 2,
}}
>
{/* Loading state — history is being fetched */}
{loading && (
<Box
sx={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Načítám
</Typography>
</Box>
)}
{/* Empty state */}
{turns.length === 0 && !busy && !loading && (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1.5,
textAlign: "center",
px: 2,
}}
>
<OdinAvatar size={44} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k
importu.
</Typography>
</Box>
)}
{/* Message bubbles */}
{turns.map((t, i) => {
const isUser = t.role === "user";
return (
<Box
key={i}
sx={{
display: "flex",
flexDirection: "row",
alignItems: "flex-end",
gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%",
}}
>
{!isUser && <OdinAvatar size={28} />}
<Box
sx={{
px: 1.5,
py: 1,
borderRadius: 2,
boxShadow: 1,
bgcolor: isUser ? "primary.main" : "background.paper",
}}
>
{/* 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. */}
<Typography
variant="body2"
sx={{
whiteSpace: "pre-wrap",
color: isUser ? "common.white" : "text.primary",
}}
>
{t.content}
</Typography>
</Box>
</Box>
);
})}
{/* Busy indicator */}
{busy && (
<Box
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 1,
color: "text.secondary",
}}
>
<CircularProgress size={14} />
<Typography variant="caption" sx={{ color: "inherit" }}>
Pracuji
</Typography>
</Box>
)}
</Box>
);
}

View File

@@ -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;
}