feat(warehouse): add reservations, inventory, reports, and dashboard pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
375
src/admin/pages/Warehouse.tsx
Normal file
375
src/admin/pages/Warehouse.tsx
Normal 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 →
|
||||
</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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user