feat(mui): migrate Warehouse overview + replace residual admin-loading loaders with LoadingState

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 01:45:04 +02:00
parent c7e42a923a
commit e4038051e9
6 changed files with 312 additions and 254 deletions

View File

@@ -34,6 +34,7 @@ import {
FilterBar,
Tabs,
PageHeader,
LoadingState,
type DataColumn,
type TabDef,
} from "../ui";
@@ -450,11 +451,7 @@ export default function Offers() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const total = pagination?.total ?? quotations.length;

View File

@@ -15,7 +15,7 @@ import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import Forbidden from "../components/Forbidden";
import { Button, Alert } from "../ui";
import { Button, Alert, LoadingState } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
@@ -670,11 +670,7 @@ export default function PlanWork() {
</Box>
</motion.div>
{gridLoading && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{gridLoading && <LoadingState />}
{/*
Grid entrance: same pattern as the h1 / banner / toolbar above

View File

@@ -31,6 +31,7 @@ import {
DateField,
StatusChip,
CheckboxField,
LoadingState,
type DataColumn,
} from "../ui";
@@ -234,11 +235,7 @@ export default function Projects() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const columns: DataColumn<Project>[] = [

View File

@@ -25,6 +25,7 @@ import {
SwitchField,
Select,
StatusChip,
LoadingState,
type DataColumn,
} from "../ui";
@@ -247,11 +248,7 @@ export default function Users() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const columns: DataColumn<User>[] = [

View File

@@ -20,6 +20,7 @@ import {
Field,
TextField,
SwitchField,
LoadingState,
type DataColumn,
} from "../ui";
@@ -222,11 +223,7 @@ export default function Vehicles() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const columns: DataColumn<Vehicle>[] = [

View File

@@ -1,32 +1,89 @@
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import { formatCurrency, formatDate } from "../utils/formatters";
import useReducedMotion from "../hooks/useReducedMotion";
import {
Button,
Card,
DataTable,
StatCard,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
type DataColumn,
type StatCardColor,
} from "../ui";
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
const TYPE_TONE: Record<string, string> = {
receipt: "admin-badge-incoming",
issue: "admin-badge-outgoing",
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
const TYPE_COLOR: Record<string, "success" | "info"> = {
receipt: "success",
issue: "info",
};
const ReceiptIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
);
const IssueIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
);
interface BelowMinRow {
id: number;
name: string;
available_quantity: number;
min_quantity: number;
}
interface MovementRow {
_idx: number;
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
supplier_or_project: string;
}
export default function Warehouse() {
const { hasPermission } = useAuth();
const reducedMotion = useReducedMotion();
const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(),
@@ -60,253 +117,270 @@ export default function Warehouse() {
const isLoading =
stockPending || belowMinPending || reservationsPending || movementsLoading;
const belowMinColor: StatCardColor =
belowMinimumCount > 0 ? "error" : "warning";
const belowMinColumns: DataColumn<BelowMinRow>[] = [
{
key: "name",
header: "Název",
width: "50%",
bold: true,
render: (item) => item.name,
},
{
key: "available_quantity",
header: "K dispozici",
width: "25%",
mono: true,
render: (item) => String(item.available_quantity),
},
{
key: "min_quantity",
header: "Min. množství",
width: "25%",
mono: true,
render: (item) => String(item.min_quantity),
},
];
const movementColumns: DataColumn<MovementRow>[] = [
{
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={TYPE_COLOR[row.type] ?? "info"}
/>
),
},
{
key: "document_number",
header: "Doklad",
width: "16%",
mono: true,
render: (row) => row.document_number || "—",
},
{
key: "item_name",
header: "Položka",
width: "28%",
bold: true,
render: (row) => row.item_name,
},
{
key: "quantity",
header: "Množství",
width: "12%",
mono: true,
render: (row) => String(row.quantity),
},
{
key: "supplier_or_project",
header: "Dodavatel / Projekt",
width: "20%",
render: (row) => row.supplier_or_project || "—",
},
];
return (
<div>
<Box>
<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">
{hasPermission("warehouse.operate") && (
<>
<Link
to="/warehouse/receipts/new"
className="admin-btn admin-btn-primary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
<PageHeader
title="Sklad"
subtitle="Přehled skladových zásob"
actions={
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasPermission("warehouse.operate") && (
<>
<Button
component={RouterLink}
to="/warehouse/receipts/new"
startIcon={ReceiptIcon}
>
Nový příjem
</Button>
<Button
component={RouterLink}
to="/warehouse/issues/new"
variant="outlined"
color="inherit"
startIcon={IssueIcon}
>
Nový výdej
</Button>
</>
)}
{hasPermission("warehouse.manage") && (
<Button
component={RouterLink}
to="/warehouse/categories"
variant="outlined"
color="inherit"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
Nový příjem
</Link>
<Link
to="/warehouse/issues/new"
className="admin-btn admin-btn-secondary"
Kategorie
</Button>
)}
<Button
component={RouterLink}
to="/warehouse/items"
variant="outlined"
color="inherit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
Nový výdej
</Link>
</>
)}
{hasPermission("warehouse.manage") && (
<Link
to="/warehouse/categories"
className="admin-btn admin-btn-secondary"
>
Kategorie
</Link>
)}
<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>
Položky
</Button>
<Button
component={RouterLink}
to="/warehouse/reports"
variant="outlined"
color="inherit"
>
Reporty
</Button>
</Box>
}
/>
</motion.div>
{/* KPI cards */}
<div className="admin-kpi-grid admin-kpi-4">
<motion.div
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.06,
}}
>
<div className="admin-stat-label">Celkem položek</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : totalItems}
</div>
</motion.div>
<motion.div
className="admin-stat-card success"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.08,
}}
>
<div className="admin-stat-label">Hodnota zásob</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
</motion.div>
<motion.div
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.1,
}}
>
<div className="admin-stat-label">Pod minimem</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : belowMinimumCount}
</div>
</motion.div>
<motion.div
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.12,
}}
>
<div className="admin-stat-label">Aktivní rezervace</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : activeReservationCount}
</div>
</motion.div>
</div>
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
>
<StatCard
color="info"
label="Celkem položek"
value={isLoading ? "—" : totalItems}
/>
<StatCard
color="success"
label="Hodnota zásob"
value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
/>
<StatCard
color={belowMinColor}
label="Pod minimem"
value={isLoading ? "—" : belowMinimumCount}
/>
<StatCard
color="info"
label="Aktivní rezervace"
value={isLoading ? "—" : activeReservationCount}
/>
</Box>
{/* Below minimum alert */}
{belowMin.length > 0 && (
<motion.div
className="admin-card admin-card-danger"
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.14,
}}
transition={{ duration: 0.25, delay: 0.14 }}
sx={{ mb: 3 }}
>
<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"
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
Reporty &rarr;
</Link>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>K dispozici</th>
<th>Min. množství</th>
</tr>
</thead>
<tbody>
{belowMin.map((item) => (
<tr key={item.id} className="admin-warehouse-row-danger">
<td className="fw-500">{item.name}</td>
<td className="admin-mono admin-warehouse-danger-value">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono">{item.min_quantity ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
<Typography variant="h6" sx={{ color: "error.main" }}>
Položky pod minimem
</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
size="small"
sx={{ flexShrink: 0 }}
>
Reporty &rarr;
</Button>
</Box>
<DataTable<BelowMinRow>
columns={belowMinColumns}
rows={belowMin.map((item) => ({
id: item.id,
name: item.name,
available_quantity: item.available_quantity ?? 0,
min_quantity: item.min_quantity ?? 0,
}))}
rowKey={(item) => item.id}
rowDanger={() => true}
/>
</Card>
</Box>
)}
{/* Recent movements */}
<motion.div
className="admin-card"
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.16,
}}
transition={{ duration: 0.25, delay: 0.16 }}
>
<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"
<Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
Vše &rarr;
</Link>
</div>
<div className="admin-card-body">
<Typography variant="h6">Poslední pohyby</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Button>
</Box>
{movementsLoading ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : recentMovements.length === 0 ? (
<div className="admin-empty-state">Žádné nedávné pohyby</div>
<LoadingState />
) : (
<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>Množství</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
<tbody>
{recentMovements.map((row, i) => (
<tr key={i}>
<td className="admin-mono">{formatDate(row.date)}</td>
<td>
<span
className={`admin-badge ${TYPE_TONE[row.type] ?? ""}`}
>
{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">{row.quantity}</td>
<td>{row.supplier_or_project || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable<MovementRow>
columns={movementColumns}
rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
rowKey={(row) => row._idx}
empty={<EmptyState title="Žádné nedávné pohyby" />}
/>
)}
</div>
</motion.div>
</div>
</Card>
</Box>
</Box>
);
}