Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
235 lines
6.8 KiB
TypeScript
235 lines
6.8 KiB
TypeScript
import { useState } from "react";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
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 OdinSidebarProps {
|
|
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 OdinSidebar({
|
|
conversations,
|
|
activeId,
|
|
busy,
|
|
onSelect,
|
|
onNew,
|
|
onRename,
|
|
onDelete,
|
|
}: OdinSidebarProps) {
|
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
const [menuConv, setMenuConv] = useState<{
|
|
id: number;
|
|
title: string;
|
|
} | null>(null);
|
|
const [renameOpen, setRenameOpen] = useState(false);
|
|
const [renameDraft, setRenameDraft] = useState("");
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
|
|
const openMenu = (
|
|
e: React.MouseEvent<HTMLElement>,
|
|
conv: { id: number; title: string },
|
|
) => {
|
|
e.stopPropagation();
|
|
setMenuConv(conv);
|
|
setAnchorEl(e.currentTarget);
|
|
};
|
|
const closeMenu = () => setAnchorEl(null);
|
|
|
|
const handleRenameOpen = () => {
|
|
closeMenu();
|
|
setRenameDraft(menuConv?.title ?? "");
|
|
setRenameOpen(true);
|
|
};
|
|
const handleRenameSubmit = () => {
|
|
if (menuConv) onRename(menuConv.id, renameDraft.trim() || menuConv.title);
|
|
setRenameOpen(false);
|
|
};
|
|
const handleDeleteOpen = () => {
|
|
closeMenu();
|
|
setDeleteOpen(true);
|
|
};
|
|
const handleDeleteConfirm = () => {
|
|
if (menuConv) onDelete(menuConv.id);
|
|
setDeleteOpen(false);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
width: { xs: 280, md: 264 },
|
|
flexShrink: 0,
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
borderRight: 1,
|
|
borderColor: "divider",
|
|
bgcolor: "action.hover",
|
|
}}
|
|
>
|
|
{/* Brand + New chat */}
|
|
<Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}>
|
|
<Box
|
|
component="span"
|
|
sx={(t) => ({
|
|
px: 0.5,
|
|
fontSize: "1.7rem",
|
|
fontWeight: 700,
|
|
lineHeight: 1.2,
|
|
letterSpacing: "-0.02em",
|
|
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
|
|
WebkitBackgroundClip: "text",
|
|
backgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
})}
|
|
>
|
|
Odin
|
|
</Box>
|
|
<Button fullWidth disabled={busy} onClick={onNew}>
|
|
+ Nová konverzace
|
|
</Button>
|
|
</Box>
|
|
|
|
{/* Conversation list */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: "auto",
|
|
px: 1,
|
|
pb: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 0.25,
|
|
}}
|
|
>
|
|
{conversations.length === 0 && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ px: 1, py: 1 }}
|
|
>
|
|
Zatím žádné konverzace.
|
|
</Typography>
|
|
)}
|
|
{conversations.map((conv) => {
|
|
const isActive = conv.id === activeId;
|
|
return (
|
|
<Box
|
|
key={conv.id}
|
|
role="button"
|
|
tabIndex={busy ? -1 : 0}
|
|
aria-pressed={isActive}
|
|
onClick={() => !busy && onSelect(conv.id)}
|
|
onKeyDown={(e) => {
|
|
// Activate the row on Enter/Space like a native button. The
|
|
// nested kebab IconButton stays a real <button> and handles its
|
|
// own keys (its onClick stopPropagation prevents row selection).
|
|
if (!busy && (e.key === "Enter" || e.key === " ")) {
|
|
e.preventDefault();
|
|
onSelect(conv.id);
|
|
}
|
|
}}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 0.5,
|
|
px: 1,
|
|
py: 0.75,
|
|
borderRadius: 1.5,
|
|
cursor: busy ? "default" : "pointer",
|
|
bgcolor: isActive ? "action.selected" : "transparent",
|
|
"&:hover": {
|
|
bgcolor: isActive ? "action.selected" : "action.hover",
|
|
},
|
|
"&:hover .odin-conv-menu": { opacity: 1 },
|
|
"&:focus-visible .odin-conv-menu": { opacity: 1 },
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="body2"
|
|
noWrap
|
|
title={conv.title}
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
color: "text.primary",
|
|
fontWeight: isActive ? 600 : 400,
|
|
}}
|
|
>
|
|
{conv.title}
|
|
</Typography>
|
|
<IconButton
|
|
className="odin-conv-menu"
|
|
size="small"
|
|
disabled={busy}
|
|
onClick={(e) => openMenu(e, conv)}
|
|
aria-label="Možnosti konverzace"
|
|
title="Možnosti konverzace"
|
|
sx={{
|
|
flexShrink: 0,
|
|
opacity: { xs: 1, md: 0 },
|
|
transition: "opacity 0.15s",
|
|
}}
|
|
>
|
|
⋮
|
|
</IconButton>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
{/* Per-conversation 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 „${menuConv?.title ?? ""}"? Tuto akci nelze vrátit.`}
|
|
confirmText="Smazat"
|
|
confirmVariant="danger"
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|