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

577 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import Box from "@mui/material/Box";
import { projectListOptions } from "../lib/queries/projects";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseCategoryListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type MovementLogRow,
} from "../lib/queries/warehouse";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
Alert,
Button,
Card,
DataTable,
Select,
DateField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
Tabs,
TabPanel,
type DataColumn,
type TabDef,
} from "../ui";
type TabId =
| "stock-status"
| "project-consumption"
| "movement-log"
| "below-minimum";
interface ProjectConsumptionRow {
item_name: string;
project_name: string;
total_qty: number;
total_value: number;
}
const TABS: TabDef[] = [
{ value: "stock-status", label: "Stav zásob" },
{ value: "project-consumption", label: "Spotřeba projektů" },
{ value: "movement-log", label: "Pohyby" },
{ value: "below-minimum", label: "Pod minimem" },
];
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info, inventory=info)
const MOVEMENT_COLOR: Record<string, "success" | "info"> = {
receipt: "success",
issue: "info",
inventory: "info",
};
export default function WarehouseReports() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<TabId>("stock-status");
// Stock status filters
const [categoryId, setCategoryId] = useState<number | "">("");
// Project consumption filters
const [consumptionProjectId, setConsumptionProjectId] = useState<number | "">(
"",
);
const [consumptionDateFrom, setConsumptionDateFrom] = useState("");
const [consumptionDateTo, setConsumptionDateTo] = useState("");
// Movement log filters
const [movementType, setMovementType] = useState<string>("");
const [movementDateFrom, setMovementDateFrom] = useState("");
const [movementDateTo, setMovementDateTo] = useState("");
if (!hasPermission("warehouse.view")) return <Forbidden />;
return (
<PageEnter>
<PageHeader title="Skladové reporty" />
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as TabId)}
tabs={TABS}
/>
<TabPanel value="stock-status" current={activeTab}>
<StockStatusTab categoryId={categoryId} setCategoryId={setCategoryId} />
</TabPanel>
<TabPanel value="project-consumption" current={activeTab}>
<ProjectConsumptionTab
projectId={consumptionProjectId}
setProjectId={setConsumptionProjectId}
dateFrom={consumptionDateFrom}
setDateFrom={setConsumptionDateFrom}
dateTo={consumptionDateTo}
setDateTo={setConsumptionDateTo}
/>
</TabPanel>
<TabPanel value="movement-log" current={activeTab}>
<MovementLogTab
type={movementType}
setType={setMovementType}
dateFrom={movementDateFrom}
setDateFrom={setMovementDateFrom}
dateTo={movementDateTo}
setDateTo={setMovementDateTo}
/>
</TabPanel>
<TabPanel value="below-minimum" current={activeTab}>
<BelowMinimumTab />
</TabPanel>
</PageEnter>
);
}
/* ---------- Tab 1: Stock Status ---------- */
function StockStatusTab({
categoryId,
setCategoryId,
}: {
categoryId: number | "";
setCategoryId: (v: number | "") => void;
}) {
const { data: categories = [] } = useQuery(warehouseCategoryListOptions());
const { data: items, isPending } = useQuery(
warehouseStockStatusOptions({
category_id: categoryId ? Number(categoryId) : undefined,
}),
);
const columns: DataColumn<WarehouseItem>[] = [
{
key: "name",
header: "Název",
width: "30%",
bold: true,
render: (item) => item.name,
},
{
key: "total_quantity",
header: "Celkem",
width: "12%",
mono: true,
render: (item) => String(item.total_quantity ?? 0),
},
{
key: "available_quantity",
header: "K dispozici",
width: "13%",
mono: true,
render: (item) => String(item.available_quantity ?? 0),
},
{
key: "stock_value",
header: "Hodnota",
width: "17%",
mono: true,
render: (item) =>
item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč",
},
{
key: "min_quantity",
header: "Min. množství",
width: "14%",
mono: true,
render: (item) => (item.min_quantity ?? "—").toString(),
},
{
key: "status",
header: "Stav",
width: "14%",
render: (item) =>
item.below_minimum ? (
<StatusChip label="Pod min." color="error" />
) : (
<StatusChip label="OK" color="success" />
),
},
];
return (
<Box>
<FilterBar>
{categories.length > 0 && (
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={categoryId === "" ? "" : String(categoryId)}
onChange={(val) => setCategoryId(val === "" ? "" : Number(val))}
options={[
{ value: "", label: "Všechny kategorie" },
...categories.map((cat) => ({
value: String(cat.id),
label: cat.name,
})),
]}
/>
</Box>
)}
</FilterBar>
{isPending ? (
<LoadingState />
) : (
<Card>
<DataTable<WarehouseItem>
columns={columns}
rows={(items as WarehouseItem[] | undefined) ?? []}
rowKey={(item) => item.id}
empty={<EmptyState title="Žádné položky" />}
/>
</Card>
)}
</Box>
);
}
/* ---------- Tab 2: Project Consumption ---------- */
function ProjectConsumptionTab({
projectId,
setProjectId,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
}: {
projectId: number | "";
setProjectId: (v: number | "") => void;
dateFrom: string;
setDateFrom: (v: string) => void;
dateTo: string;
setDateTo: (v: string) => void;
}) {
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects =
(projectsData?.data as {
id: number;
project_number: string;
name: string;
}[]) ?? [];
// Filters applied on "Zobrazit"; the query key carries them so results land in
// the ["warehouse"] cache and refresh on warehouse mutations.
const [applied, setApplied] = useState<{
projectId: number | "";
dateFrom: string;
dateTo: string;
} | null>(null);
const {
data,
isFetching: loading,
error,
} = useQuery({
queryKey: ["warehouse", "project-consumption", applied],
enabled: applied !== null,
queryFn: () => {
const params = new URLSearchParams();
if (applied?.projectId)
params.set("project_id", String(applied.projectId));
if (applied?.dateFrom) params.set("date_from", applied.dateFrom);
if (applied?.dateTo) params.set("date_to", applied.dateTo);
const qs = params.toString();
return jsonQuery<ProjectConsumptionRow[]>(
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
);
},
});
const fetched = applied !== null;
const handleFetch = () => setApplied({ projectId, dateFrom, dateTo });
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
{
key: "item_name",
header: "Položka",
width: "32%",
bold: true,
render: (row) => row.item_name,
},
{
key: "project_name",
header: "Projekt",
width: "32%",
render: (row) => row.project_name,
},
{
key: "total_qty",
header: "Množství",
width: "18%",
mono: true,
render: (row) => String(row.total_qty),
},
{
key: "total_value",
header: "Hodnota",
width: "18%",
mono: true,
render: (row) => formatCurrency(row.total_value, "CZK"),
},
];
return (
<Box>
<FilterBar>
{projects.length > 0 && (
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={projectId === "" ? "" : String(projectId)}
onChange={(val) => setProjectId(val === "" ? "" : Number(val))}
options={[
{ value: "", label: "Všechny projekty" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
)}
<Box sx={{ flex: "0 0 150px" }}>
<DateField value={dateFrom} onChange={setDateFrom} label="Od" />
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField value={dateTo} onChange={setDateTo} label="Do" />
</Box>
<Button type="button" onClick={handleFetch} disabled={loading}>
{loading ? "Načítání..." : "Zobrazit"}
</Button>
</FilterBar>
{!fetched && <EmptyState title="Zvolte filtr a klikněte na Zobrazit" />}
{fetched && loading && <LoadingState />}
{fetched && !loading && error && (
<Alert severity="error">
{error instanceof Error
? error.message
: "Nepodařilo se načíst spotřebu projektů"}
</Alert>
)}
{fetched && !loading && !error && data && (
<Card>
<DataTable<ProjectConsumptionRow & { _idx: number }>
columns={columns}
rows={data.map((row, i) => ({ ...row, _idx: i }))}
rowKey={(row) => row._idx}
empty={<EmptyState title="Žádná data" />}
/>
</Card>
)}
</Box>
);
}
/* ---------- Tab 3: Movement Log ---------- */
function MovementLogTab({
type,
setType,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
}: {
type: string;
setType: (v: string) => void;
dateFrom: string;
setDateFrom: (v: string) => void;
dateTo: string;
setDateTo: (v: string) => void;
}) {
// Filters applied on "Zobrazit"; uses the shared movement-log query option so
// results land in the ["warehouse"] cache and refresh on warehouse mutations.
const [applied, setApplied] = useState<{
type: string;
dateFrom: string;
dateTo: string;
} | null>(null);
const {
data,
isFetching: loading,
error,
} = useQuery({
...warehouseMovementLogOptions({
type: applied?.type || undefined,
date_from: applied?.dateFrom || undefined,
date_to: applied?.dateTo || undefined,
}),
enabled: applied !== null,
});
const fetched = applied !== null;
const handleFetch = () => setApplied({ type, dateFrom, dateTo });
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
const columns: DataColumn<MovementLogRow & { _idx: number }>[] = [
{
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={MOVEMENT_COLOR[row.type] ?? "info"}
/>
),
},
{
key: "document_number",
header: "Doklad",
width: "14%",
mono: true,
render: (row) => row.document_number || "—",
},
{
key: "item_name",
header: "Položka",
width: "22%",
bold: true,
render: (row) => row.item_name,
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (row) => String(row.quantity),
},
{
key: "unit_price",
header: "Cena/ks",
width: "12%",
mono: true,
render: (row) => formatCurrency(row.unit_price, "CZK"),
},
{
key: "supplier_or_project",
header: "Dodavatel / Projekt",
width: "18%",
render: (row) => row.supplier_or_project || "—",
},
];
return (
<Box>
<FilterBar>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={type}
onChange={setType}
options={[
{ value: "", label: "Všechny typy" },
{ value: "receipt", label: "Příjem" },
{ value: "issue", label: "Výdej" },
]}
/>
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField value={dateFrom} onChange={setDateFrom} label="Od" />
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField value={dateTo} onChange={setDateTo} label="Do" />
</Box>
<Button type="button" onClick={handleFetch} disabled={loading}>
{loading ? "Načítání..." : "Zobrazit"}
</Button>
</FilterBar>
{!fetched && <EmptyState title="Zvolte filtr a klikněte na Zobrazit" />}
{fetched && loading && <LoadingState />}
{fetched && !loading && error && (
<Alert severity="error">
{error instanceof Error
? error.message
: "Nepodařilo se načíst pohyby"}
</Alert>
)}
{fetched && !loading && !error && data && (
<Card>
<DataTable<MovementLogRow & { _idx: number }>
columns={columns}
rows={data.map((row, i) => ({ ...row, _idx: i }))}
rowKey={(row) => row._idx}
empty={<EmptyState title="Žádná data" />}
/>
</Card>
)}
</Box>
);
}
/* ---------- Tab 4: Below Minimum ---------- */
function BelowMinimumTab() {
const { data: items, isPending } = useQuery(warehouseBelowMinimumOptions());
const columns: DataColumn<WarehouseItem>[] = [
{
key: "name",
header: "Název",
width: "40%",
bold: true,
render: (item) => item.name,
},
{
key: "total_quantity",
header: "Celkem",
width: "20%",
mono: true,
render: (item) => String(item.total_quantity ?? 0),
},
{
key: "available_quantity",
header: "K dispozici",
width: "20%",
mono: true,
render: (item) => String(item.available_quantity ?? 0),
},
{
key: "min_quantity",
header: "Min. množství",
width: "20%",
mono: true,
bold: true,
render: (item) => String(item.min_quantity ?? 0),
},
];
if (isPending) {
return <LoadingState />;
}
return (
<Card>
<DataTable<WarehouseItem>
columns={columns}
rows={(items as WarehouseItem[] | undefined) ?? []}
rowKey={(item) => item.id}
rowDanger={() => true}
empty={<EmptyState title="Všechny položky jsou nad minimem" />}
/>
</Card>
);
}