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>
360 lines
9.2 KiB
TypeScript
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 →
|
|
</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 →
|
|
</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>
|
|
);
|
|
}
|