feat(warehouse): add reservations, inventory, reports, and dashboard pages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-29 14:36:31 +02:00
parent fdc7037e60
commit f389c4b3cf
6 changed files with 2240 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
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 [recentMovements, setRecentMovements] = useState<MovementLogRow[]>([]);
const [movementsLoading, setMovementsLoading] = useState(true);
useEffect(() => {
async function fetchMovements() {
try {
const response = await apiFetch(
"/api/admin/warehouse/reports/movement-log?limit=10",
);
const result = await response.json();
if (result.success) {
setRecentMovements(result.data ?? []);
}
} catch {
// Non-fatal, show empty
} finally {
setMovementsLoading(false);
}
}
fetchMovements();
}, []);
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;
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Sklad</h1>
<p className="admin-page-subtitle">Přehled skladových zásob</p>
</div>
<div className="admin-page-actions">
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
Položky
</Link>
<Link
to="/warehouse/reports"
className="admin-btn admin-btn-secondary"
>
Reporty
</Link>
</div>
</motion.div>
{/* KPI cards */}
<div
className="dash-kpi-cards"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "1rem",
marginBottom: "1.5rem",
}}
>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Celkem položek
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : totalItems}
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Hodnota zásob
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
style={
belowMinimumCount > 0
? { border: "2px solid var(--danger)" }
: undefined
}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Pod minimem
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-primary)",
}}
>
{isLoading ? "—" : belowMinimumCount}
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Aktivní rezervace
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : activeReservationCount}
</div>
</div>
</motion.div>
</div>
{/* Below minimum alert */}
{belowMin.length > 0 && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
style={{ border: "2px solid var(--danger)" }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
Položky pod minimem
</h2>
<Link
to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Reporty &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th className="text-right">K dispozici</th>
<th className="text-right">Min. množství</th>
</tr>
</thead>
<tbody>
{belowMin.map((item) => (
<tr key={item.id}>
<td className="fw-500">{item.name}</td>
<td
className="admin-mono text-right"
style={{ color: "var(--danger)" }}
>
{item.available_quantity ?? 0}
</td>
<td className="admin-mono text-right">
{item.min_quantity ?? 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
)}
{/* Recent movements */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.16 }}
style={{ marginTop: "1.5rem" }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední pohyby</h2>
<Link
to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Vše &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{movementsLoading ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
}}
>
<div className="admin-spinner admin-spinner-sm" />
</div>
) : recentMovements.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Žádné nedávné pohyby
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Doklad</th>
<th>Položka</th>
<th className="text-right">Množství</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
<tbody>
{recentMovements.map((row, i) => (
<tr key={i}>
<td className="admin-mono">{row.date}</td>
<td>
<span
className={`admin-badge ${row.type === "receipt" ? "admin-badge-active" : "admin-badge-info"}`}
>
{TYPE_LABEL[row.type] ?? row.type}
</span>
</td>
<td className="admin-mono">
{row.document_number || "—"}
</td>
<td className="fw-500">{row.item_name}</td>
<td className="admin-mono text-right">{row.quantity}</td>
<td>{row.supplier_or_project || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</div>
);
}