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>
577 lines
15 KiB
TypeScript
577 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|