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,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>
);
}