Files
app/src/admin/pages/WarehouseReports.tsx
BOHA ff527ca577 feat(mui): migrate Warehouse Reports (Reporty) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:59:18 +02:00

583 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 { motion } from "framer-motion";
import Box from "@mui/material/Box";
import { projectListOptions } from "../lib/queries/projects";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseCategoryListOptions,
type WarehouseItem,
} from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
import {
Button,
Card,
DataTable,
Select,
DateField,
StatusChip,
PageHeader,
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;
}
interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
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 (
<Box>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<PageHeader title="Skladové reporty" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as TabId)}
tabs={TABS}
/>
</motion.div>
<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>
</Box>
);
}
/* ---------- 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;
}[]) ?? [];
const [data, setData] = useState<ProjectConsumptionRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const handleFetch = async () => {
setLoading(true);
setFetched(true);
try {
const params = new URLSearchParams();
if (projectId) params.set("project_id", String(projectId));
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
const qs = params.toString();
const response = await apiFetch(
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
);
const result = await response.json();
if (result.success) {
setData(result.data ?? []);
} else {
setData([]);
}
} catch {
setData([]);
} finally {
setLoading(false);
}
};
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 280px" }}>
<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 && 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;
}) {
const [data, setData] = useState<MovementLogRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const handleFetch = async () => {
setLoading(true);
setFetched(true);
try {
const params = new URLSearchParams();
if (type) params.set("type", type);
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
const qs = params.toString();
const response = await apiFetch(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
const result = await response.json();
if (result.success) {
setData(result.data ?? []);
} else {
setData([]);
}
} catch {
setData([]);
} finally {
setLoading(false);
}
};
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) => 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 && 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>
);
}