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
to="/warehouse/receipts/new" component={RouterLink}
className="admin-btn admin-btn-primary" to="/warehouse/receipts/new"
> startIcon={ReceiptIcon}
<svg >
width="18" Nový příjem
height="18" </Button>
viewBox="0 0 24 24" <Button
fill="none" component={RouterLink}
stroke="currentColor" to="/warehouse/issues/new"
strokeWidth="2" 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" /> Kategorie
<line x1="12" y1="6" x2="12" y2="18" /> </Button>
</svg> )}
Nový příjem <Button
</Link> component={RouterLink}
<Link to="/warehouse/items"
to="/warehouse/issues/new" variant="outlined"
className="admin-btn admin-btn-secondary" color="inherit"
> >
<svg Položky
width="18" </Button>
height="18" <Button
viewBox="0 0 24 24" component={RouterLink}
fill="none" to="/warehouse/reports"
stroke="currentColor" variant="outlined"
strokeWidth="2" color="inherit"
> >
<polyline points="7 13 12 18 17 13" /> Reporty
<line x1="12" y1="18" x2="12" y2="6" /> </Button>
</svg> </Box>
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>
</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={{ duration: 0.25, delay: 0.06 }}
transition={{ sx={{
duration: reducedMotion ? 0 : 0.25, display: "grid",
delay: reducedMotion ? 0 : 0.06, gridTemplateColumns: {
}} xs: "1fr",
> sm: "repeat(2, 1fr)",
<div className="admin-stat-label">Celkem položek</div> lg: "repeat(4, 1fr)",
<div className="admin-stat-value admin-mono"> },
{isLoading ? "—" : totalItems} gap: 2,
</div> mb: 3,
</motion.div> }}
>
<motion.div <StatCard
className="admin-stat-card success" color="info"
initial={{ opacity: 0, y: 12 }} label="Celkem položek"
animate={{ opacity: 1, y: 0 }} value={isLoading ? "—" : totalItems}
transition={{ />
duration: reducedMotion ? 0 : 0.25, <StatCard
delay: reducedMotion ? 0 : 0.08, color="success"
}} label="Hodnota zásob"
> value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
<div className="admin-stat-label">Hodnota zásob</div> />
<div className="admin-stat-value admin-mono"> <StatCard
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")} color={belowMinColor}
</div> label="Pod minimem"
</motion.div> value={isLoading ? "—" : belowMinimumCount}
/>
<motion.div <StatCard
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`} color="info"
initial={{ opacity: 0, y: 12 }} label="Aktivní rezervace"
animate={{ opacity: 1, y: 0 }} value={isLoading ? "—" : activeReservationCount}
transition={{ />
duration: reducedMotion ? 0 : 0.25, </Box>
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,
}}
> >
<div className="admin-card-header flex-between"> <Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}> <Box
Položky pod minimem sx={{
</h2> display: "flex",
<Link alignItems: "center",
to="/warehouse/reports" justifyContent: "space-between",
className="admin-btn admin-btn-primary admin-btn-sm" gap: 2,
flexWrap: "wrap",
mb: 2,
}}
> >
Reporty &rarr; <Typography variant="h6" sx={{ color: "error.main" }}>
</Link> Položky pod minimem
</div> </Typography>
<div className="admin-card-body"> <Button
<div className="admin-table-responsive"> component={RouterLink}
<table className="admin-table"> to="/warehouse/reports"
<thead> size="small"
<tr> sx={{ flexShrink: 0 }}
<th>Název</th> >
<th>K dispozici</th> Reporty &rarr;
<th>Min. množství</th> </Button>
</tr> </Box>
</thead> <DataTable<BelowMinRow>
<tbody> columns={belowMinColumns}
{belowMin.map((item) => ( rows={belowMin.map((item) => ({
<tr key={item.id} className="admin-warehouse-row-danger"> id: item.id,
<td className="fw-500">{item.name}</td> name: item.name,
<td className="admin-mono admin-warehouse-danger-value"> available_quantity: item.available_quantity ?? 0,
{item.available_quantity ?? 0} min_quantity: item.min_quantity ?? 0,
</td> }))}
<td className="admin-mono">{item.min_quantity ?? 0}</td> rowKey={(item) => item.id}
</tr> rowDanger={() => true}
))} />
</tbody> </Card>
</table> </Box>
</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,
}}
> >
<div className="admin-card-header flex-between"> <Card>
<h2 className="admin-card-title">Poslední pohyby</h2> <Box
<Link sx={{
to="/warehouse/reports" display: "flex",
className="admin-btn admin-btn-primary admin-btn-sm" alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
> >
Vše &rarr; <Typography variant="h6">Poslední pohyby</Typography>
</Link> <Button
</div> component={RouterLink}
<div className="admin-card-body"> to="/warehouse/reports"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Button>
</Box>
{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>
); );
} }