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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import {
Field, Field,
TextField, TextField,
SwitchField, SwitchField,
LoadingState,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -222,11 +223,7 @@ export default function Vehicles() {
}; };
if (isPending) { if (isPending) {
return ( return <LoadingState />;
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
} }
const columns: DataColumn<Vehicle>[] = [ 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 { 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 { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import { import {
warehouseStockStatusOptions, warehouseStockStatusOptions,
warehouseBelowMinimumOptions, warehouseBelowMinimumOptions,
warehouseReservationListOptions, warehouseReservationListOptions,
warehouseMovementLogOptions, warehouseMovementLogOptions,
type WarehouseItem, type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { formatCurrency, formatDate } from "../utils/formatters"; 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> = { const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem", receipt: "Příjem",
issue: "Výdej", issue: "Výdej",
}; };
const TYPE_TONE: Record<string, string> = { // Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
receipt: "admin-badge-incoming", const TYPE_COLOR: Record<string, "success" | "info"> = {
issue: "admin-badge-outgoing", 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() { export default function Warehouse() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const reducedMotion = useReducedMotion();
const { data: stockItems, isPending: stockPending } = useQuery( const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(), warehouseStockStatusOptions(),
@@ -60,253 +117,270 @@ export default function Warehouse() {
const isLoading = const isLoading =
stockPending || belowMinPending || reservationsPending || movementsLoading; 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 ( return (
<div> <Box>
<motion.div <motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
> >
<div> <PageHeader
<h1 className="admin-page-title">Sklad</h1> title="Sklad"
<p className="admin-page-subtitle">Přehled skladových zásob</p> subtitle="Přehled skladových zásob"
</div> actions={
<div className="admin-page-actions"> <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasPermission("warehouse.operate") && ( {hasPermission("warehouse.operate") && (
<> <>
<Link <Button
component={RouterLink}
to="/warehouse/receipts/new" to="/warehouse/receipts/new"
className="admin-btn admin-btn-primary" startIcon={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>
Nový příjem Nový příjem
</Link> </Button>
<Link <Button
component={RouterLink}
to="/warehouse/issues/new" to="/warehouse/issues/new"
className="admin-btn admin-btn-secondary" variant="outlined"
color="inherit"
startIcon={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>
Nový výdej Nový výdej
</Link> </Button>
</> </>
)} )}
{hasPermission("warehouse.manage") && ( {hasPermission("warehouse.manage") && (
<Link <Button
component={RouterLink}
to="/warehouse/categories" to="/warehouse/categories"
className="admin-btn admin-btn-secondary" variant="outlined"
color="inherit"
> >
Kategorie Kategorie
</Link> </Button>
)} )}
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary"> <Button
component={RouterLink}
to="/warehouse/items"
variant="outlined"
color="inherit"
>
Položky Položky
</Link> </Button>
<Link <Button
component={RouterLink}
to="/warehouse/reports" to="/warehouse/reports"
className="admin-btn admin-btn-secondary" variant="outlined"
color="inherit"
> >
Reporty Reporty
</Link> </Button>
</div> </Box>
}
/>
</motion.div> </motion.div>
{/* KPI cards */} {/* KPI cards */}
<div className="admin-kpi-grid admin-kpi-4"> <Box
<motion.div component={motion.div}
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{ duration: 0.25, delay: 0.06 }}
duration: reducedMotion ? 0 : 0.25, sx={{
delay: reducedMotion ? 0 : 0.06, display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}} }}
> >
<div className="admin-stat-label">Celkem položek</div> <StatCard
<div className="admin-stat-value admin-mono"> color="info"
{isLoading ? "—" : totalItems} label="Celkem položek"
</div> value={isLoading ? "—" : totalItems}
</motion.div> />
<StatCard
<motion.div color="success"
className="admin-stat-card success" label="Hodnota zásob"
initial={{ opacity: 0, y: 12 }} value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
animate={{ opacity: 1, y: 0 }} />
transition={{ <StatCard
duration: reducedMotion ? 0 : 0.25, color={belowMinColor}
delay: reducedMotion ? 0 : 0.08, label="Pod minimem"
}} value={isLoading ? "—" : belowMinimumCount}
> />
<div className="admin-stat-label">Hodnota zásob</div> <StatCard
<div className="admin-stat-value admin-mono"> color="info"
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")} label="Aktivní rezervace"
</div> value={isLoading ? "—" : activeReservationCount}
</motion.div> />
</Box>
<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>
{/* Below minimum alert */} {/* Below minimum alert */}
{belowMin.length > 0 && ( {belowMin.length > 0 && (
<motion.div <Box
className="admin-card admin-card-danger" component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{ duration: 0.25, delay: 0.14 }}
duration: reducedMotion ? 0 : 0.25, sx={{ mb: 3 }}
delay: reducedMotion ? 0 : 0.14, >
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}} }}
> >
<div className="admin-card-header flex-between"> <Typography variant="h6" sx={{ color: "error.main" }}>
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
Položky pod minimem Položky pod minimem
</h2> </Typography>
<Link <Button
component={RouterLink}
to="/warehouse/reports" to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Reporty &rarr; Reporty &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body"> <DataTable<BelowMinRow>
<div className="admin-table-responsive"> columns={belowMinColumns}
<table className="admin-table"> rows={belowMin.map((item) => ({
<thead> id: item.id,
<tr> name: item.name,
<th>Název</th> available_quantity: item.available_quantity ?? 0,
<th>K dispozici</th> min_quantity: item.min_quantity ?? 0,
<th>Min. množství</th> }))}
</tr> rowKey={(item) => item.id}
</thead> rowDanger={() => true}
<tbody> />
{belowMin.map((item) => ( </Card>
<tr key={item.id} className="admin-warehouse-row-danger"> </Box>
<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>
)} )}
{/* Recent movements */} {/* Recent movements */}
<motion.div <Box
className="admin-card" component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{ duration: 0.25, delay: 0.16 }}
duration: reducedMotion ? 0 : 0.25, >
delay: reducedMotion ? 0 : 0.16, <Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}} }}
> >
<div className="admin-card-header flex-between"> <Typography variant="h6">Poslední pohyby</Typography>
<h2 className="admin-card-title">Poslední pohyby</h2> <Button
<Link component={RouterLink}
to="/warehouse/reports" to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Vše &rarr; Vše &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body">
{movementsLoading ? ( {movementsLoading ? (
<div className="admin-loading"> <LoadingState />
<div className="admin-spinner" />
</div>
) : recentMovements.length === 0 ? (
<div className="admin-empty-state">Žádné nedávné pohyby</div>
) : ( ) : (
<div className="admin-table-responsive"> <DataTable<MovementRow>
<table className="admin-table"> columns={movementColumns}
<thead> rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
<tr> rowKey={(row) => row._idx}
<th>Datum</th> empty={<EmptyState title="Žádné nedávné pohyby" />}
<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>
)} )}
</div> </Card>
</motion.div> </Box>
</div> </Box>
); );
} }