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