Files
app/src/admin/pages/Warehouse.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

360 lines
9.2 KiB
TypeScript

import { 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 { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type MovementLogRow,
} from "../lib/queries/warehouse";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
Button,
Card,
DataTable,
StatCard,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
PageEnter,
type DataColumn,
type StatCardColor,
} from "../ui";
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
const TYPE_COLOR: Record<string, "success" | "info"> = {
receipt: "success",
issue: "info",
};
const ReceiptIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
);
const IssueIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
);
interface BelowMinRow {
id: number;
name: string;
available_quantity: number;
min_quantity: number;
}
// Dashboard movement row = the shared lib row plus a stable list index for rowKey.
type MovementRow = MovementLogRow & { _idx: number };
export default function Warehouse() {
const { hasPermission } = useAuth();
const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(),
);
const { data: belowMinItems, isPending: belowMinPending } = useQuery(
warehouseBelowMinimumOptions(),
);
const { data: reservationsData, isPending: reservationsPending } = useQuery(
warehouseReservationListOptions({ status: "ACTIVE", perPage: 1 }),
);
const { data: recentMovements = [], isPending: movementsLoading } = useQuery(
warehouseMovementLogOptions({ limit: 10 }),
);
if (!hasPermission("warehouse.view")) return <Forbidden />;
const items = (stockItems as WarehouseItem[] | undefined) ?? [];
const belowMin = (belowMinItems as WarehouseItem[] | undefined) ?? [];
const activeReservationCount =
(reservationsData as { pagination?: { total: number } } | undefined)
?.pagination?.total ?? 0;
const totalItems = items.length;
const totalStockValue = items.reduce(
(sum, item) => sum + (item.stock_value ?? 0),
0,
);
const belowMinimumCount = belowMin.length;
const isLoading =
stockPending || belowMinPending || reservationsPending || movementsLoading;
const belowMinColor: StatCardColor =
belowMinimumCount > 0 ? "error" : "warning";
const belowMinColumns: DataColumn<BelowMinRow>[] = [
{
key: "name",
header: "Název",
width: "50%",
bold: true,
render: (item) => item.name,
},
{
key: "available_quantity",
header: "K dispozici",
width: "25%",
mono: true,
render: (item) => String(item.available_quantity),
},
{
key: "min_quantity",
header: "Min. množství",
width: "25%",
mono: true,
render: (item) => String(item.min_quantity),
},
];
const movementColumns: DataColumn<MovementRow>[] = [
{
key: "date",
header: "Datum",
width: "12%",
mono: true,
render: (row) => formatDate(row.date),
},
{
key: "type",
header: "Typ",
width: "12%",
render: (row) => (
<StatusChip
label={TYPE_LABEL[row.type] ?? row.type}
color={TYPE_COLOR[row.type] ?? "info"}
/>
),
},
{
key: "document_number",
header: "Doklad",
width: "16%",
mono: true,
render: (row) => row.document_number || "—",
},
{
key: "item_name",
header: "Položka",
width: "28%",
bold: true,
render: (row) => row.item_name,
},
{
key: "quantity",
header: "Množství",
width: "12%",
mono: true,
render: (row) => String(row.quantity),
},
{
key: "supplier_or_project",
header: "Dodavatel / Projekt",
width: "20%",
render: (row) => row.supplier_or_project || "—",
},
];
return (
<PageEnter>
<PageHeader
title="Sklad"
subtitle="Přehled skladových zásob"
actions={
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasPermission("warehouse.operate") && (
<>
<Button
component={RouterLink}
to="/warehouse/receipts/new"
startIcon={ReceiptIcon}
>
Nový příjem
</Button>
<Button
component={RouterLink}
to="/warehouse/issues/new"
variant="outlined"
color="inherit"
startIcon={IssueIcon}
>
Nový výdej
</Button>
</>
)}
{hasPermission("warehouse.manage") && (
<Button
component={RouterLink}
to="/warehouse/categories"
variant="outlined"
color="inherit"
>
Kategorie
</Button>
)}
<Button
component={RouterLink}
to="/warehouse/items"
variant="outlined"
color="inherit"
>
Položky
</Button>
<Button
component={RouterLink}
to="/warehouse/reports"
variant="outlined"
color="inherit"
>
Reporty
</Button>
</Box>
}
/>
{/* KPI cards */}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
>
<StatCard
color="info"
label="Celkem položek"
value={isLoading ? "—" : totalItems}
/>
<StatCard
color="success"
label="Hodnota zásob"
value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
/>
<StatCard
color={belowMinColor}
label="Pod minimem"
value={isLoading ? "—" : belowMinimumCount}
/>
<StatCard
color="info"
label="Aktivní rezervace"
value={isLoading ? "—" : activeReservationCount}
/>
</Box>
{/* Below minimum alert */}
{belowMin.length > 0 && (
<Box sx={{ mb: 3 }}>
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
<Typography variant="h6" sx={{ color: "error.main" }}>
Položky pod minimem
</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
size="small"
sx={{ flexShrink: 0 }}
>
Reporty &rarr;
</Button>
</Box>
<DataTable<BelowMinRow>
columns={belowMinColumns}
rows={belowMin.map((item) => ({
id: item.id,
name: item.name,
available_quantity: item.available_quantity ?? 0,
min_quantity: item.min_quantity ?? 0,
}))}
rowKey={(item) => item.id}
rowDanger={() => true}
/>
</Card>
</Box>
)}
{/* Recent movements */}
<Box>
<Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
<Typography variant="h6">Poslední pohyby</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Button>
</Box>
{movementsLoading ? (
<LoadingState />
) : (
<DataTable<MovementRow>
columns={movementColumns}
rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
rowKey={(row) => row._idx}
empty={<EmptyState title="Žádné nedávné pohyby" />}
/>
)}
</Card>
</Box>
</PageEnter>
);
}