Files
app/src/admin/pages/WarehouseInventoryDetail.tsx
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
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>
2026-06-09 06:45:26 +02:00

297 lines
7.5 KiB
TypeScript

import { useState } from "react";
import { useParams, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/formatters";
import {
warehouseInventoryDetailOptions,
type WarehouseInventorySession,
type WarehouseInventoryItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
PageEnter,
ConfirmDialog,
headerActionsSx,
type DataColumn,
} from "../ui";
const STATUS_COLOR: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
DRAFT: "warning",
CONFIRMED: "success",
};
const STATUS_LABEL: Record<string, string> = {
DRAFT: "Návrh",
CONFIRMED: "Potvrzeno",
};
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function WarehouseInventoryDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const {
data: session,
isPending,
error,
} = useQuery(warehouseInventoryDetailOptions(id));
const confirmMutation = useApiMutation<number, void>({
url: (sessionId) =>
`/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setShowConfirmModal(false);
alert.success("Inventura byla potvrzena");
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const handleConfirm = async () => {
if (!session) return;
try {
await confirmMutation.mutateAsync(session.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
if (isPending) {
return <LoadingState />;
}
if (error || !session) {
return (
<Box>
<PageHeader
title="Inventura"
actions={
<Button
component={RouterLink}
to="/warehouse/inventory"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
}
/>
<Card>
<EmptyState title="Inventura nebyla nalezena." />
</Card>
</Box>
);
}
const s = session as WarehouseInventorySession;
const items = s.items ?? [];
const itemColumns: DataColumn<WarehouseInventoryItem>[] = [
{
key: "item",
header: "Položka",
width: "25%",
bold: true,
render: (row) => row.item?.name || `ID: ${row.item_id}`,
},
{
key: "location",
header: "Lokace",
width: "15%",
mono: true,
render: (row) => row.location?.code || "—",
},
{
key: "system_qty",
header: "Systémové množství",
width: "18%",
mono: true,
render: (row) => String(Number(row.system_qty)),
},
{
key: "actual_qty",
header: "Skutečné množství",
width: "18%",
mono: true,
render: (row) => String(Number(row.actual_qty)),
},
{
key: "difference",
header: "Rozdíl",
width: "12%",
render: (row) => {
const diff = Number(row.difference);
const color =
diff > 0
? "success.main"
: diff < 0
? "error.main"
: "text.secondary";
return (
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color,
}}
>
{diff > 0 ? `+${diff}` : diff}
</Typography>
);
},
},
{
key: "notes",
header: "Poznámka",
width: "12%",
render: (row) => row.notes || "—",
},
];
return (
<PageEnter>
{/* Header */}
<PageHeader
title={s.session_number || "Inventura"}
actions={
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/inventory"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[s.status] ?? s.status}
color={STATUS_COLOR[s.status] ?? "default"}
/>
{s.status === "DRAFT" && (
<Button
onClick={() => setShowConfirmModal(true)}
disabled={confirming}
>
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
</Button>
)}
</Box>
}
/>
{/* Header info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo inventury
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{s.session_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(s.created_at)}
</Typography>
</Box>
{s.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{s.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<WarehouseInventoryItem>
columns={itemColumns}
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
{/* Confirm modal */}
<ConfirmDialog
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
title="Potvrdit inventuru"
message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`}
confirmText="Potvrdit inventuru"
loading={confirming}
/>
</PageEnter>
);
}