feat(mui): migrate WarehouseItemDetail onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 07:56:36 +02:00
parent c997a22a3c
commit c228d7a8ca

View File

@@ -1,11 +1,13 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import MenuItem from "@mui/material/MenuItem";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
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 FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import { formatCurrency, formatDate } from "../utils/formatters"; import { formatCurrency, formatDate } from "../utils/formatters";
@@ -15,6 +17,20 @@ import {
type WarehouseItem, type WarehouseItem,
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Field,
TextField,
Select,
StatCard,
EmptyState,
LoadingState,
PageHeader,
ConfirmDialog,
type DataColumn,
} from "../ui";
interface Batch { interface Batch {
id: number; id: number;
@@ -52,6 +68,35 @@ interface ItemForm {
notes: string; notes: string;
} }
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function WarehouseItemDetail() { export default function WarehouseItemDetail() {
const { id } = useParams(); const { id } = useParams();
const alert = useAlert(); const alert = useAlert();
@@ -69,6 +114,7 @@ export default function WarehouseItemDetail() {
notes: "", notes: "",
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const isNew = id === "new"; const isNew = id === "new";
const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew)); const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew));
@@ -176,62 +222,24 @@ export default function WarehouseItemDetail() {
const saving = saveMutation.isPending; const saving = saveMutation.isPending;
if (isPending && !isNew) { if (isPending && !isNew) {
return ( return <LoadingState />;
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
} }
const batches: Batch[] = item?.batches ?? []; const batches: Batch[] = item?.batches ?? [];
const locations: ItemLocation[] = item?.item_locations ?? []; const locations: ItemLocation[] = item?.item_locations ?? [];
return ( const title = isNew ? "Nová položka" : item ? item.name : "Detail položky";
<div> const subtitle = item?.item_number || undefined;
{/* Header */}
<motion.div // Edit / view mode action buttons
className="admin-page-header" const headerActions = canManage ? (
initial={{ opacity: 0, y: 12 }} <Box sx={{ display: "flex", gap: 1 }}>
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
to="/warehouse/items"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{id === "new"
? "Nová položka"
: item
? item.name
: "Detail položky"}
</h1>
{item?.item_number && (
<p className="admin-page-subtitle">{item.item_number}</p>
)}
</div>
</div>
{canManage && (
<div className="admin-page-actions">
{editing || isNew ? ( {editing || isNew ? (
<> <>
{!isNew && ( {!isNew && (
<button <Button
variant="outlined"
color="inherit"
onClick={() => { onClick={() => {
setEditing(false); setEditing(false);
setErrors({}); setErrors({});
@@ -252,349 +260,421 @@ export default function WarehouseItemDetail() {
}); });
} }
}} }}
className="admin-btn admin-btn-secondary"
disabled={saving} disabled={saving}
> >
Zrušit Zrušit
</button> </Button>
)} )}
<button <Button onClick={handleSave} disabled={saving}>
onClick={handleSave} {saving ? "Ukládání..." : isNew ? "Vytvořit" : "Uložit"}
className="admin-btn admin-btn-primary" </Button>
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : isNew ? (
"Vytvořit"
) : (
"Uložit"
)}
</button>
</> </>
) : ( ) : (
<> <>
{item && ( {item && (
<button <Button
onClick={handleDelete} variant="outlined"
className="admin-btn admin-btn-secondary" color="error"
style={{ color: "var(--danger)" }} onClick={() => setShowDeleteConfirm(true)}
> >
Smazat Smazat
</button> </Button>
)} )}
<button <Button startIcon={EditIcon} onClick={() => setEditing(true)}>
onClick={() => setEditing(true)}
className="admin-btn admin-btn-primary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Upravit Upravit
</button> </Button>
</> </>
)} )}
</div> </Box>
)} ) : undefined;
// Batch table columns
const batchColumns: DataColumn<Batch>[] = [
{
key: "received_at",
header: "Datum příjmu",
width: "22%",
mono: true,
render: (b) => formatDate(b.received_at),
},
{
key: "original_qty",
header: "Původní množství",
width: "20%",
mono: true,
render: (b) => String(Number(b.original_qty)),
},
{
key: "quantity",
header: "Zůstatek",
width: "18%",
mono: true,
bold: true,
render: (b) => String(Number(b.quantity)),
},
{
key: "unit_price",
header: "Cena za jednotku",
width: "20%",
mono: true,
render: (b) => formatCurrency(Number(b.unit_price), "CZK"),
},
{
key: "value",
header: "Hodnota",
width: "20%",
mono: true,
render: (b) =>
formatCurrency(Number(b.quantity) * Number(b.unit_price), "CZK"),
},
];
// Location table columns
const locationColumns: DataColumn<ItemLocation>[] = [
{
key: "code",
header: "Kód",
width: "30%",
mono: true,
bold: true,
render: (l) => l.location?.code || "—",
},
{
key: "name",
header: "Název",
width: "50%",
render: (l) => l.location?.name || "—",
},
{
key: "quantity",
header: "Množství",
width: "20%",
mono: true,
bold: true,
render: (l) => String(Number(l.quantity)),
},
];
return (
<Box>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<PageHeader
title={title}
subtitle={subtitle}
actions={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
component={RouterLink}
to="/warehouse/items"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
{headerActions}
</Box>
}
/>
</motion.div> </motion.div>
{/* Basic info */} {/* Basic info */}
<motion.div <motion.div
className="admin-card"
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={{ duration: 0.25, delay: 0.06 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Základní údaje</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
<div className="admin-form"> Základní údaje
{editing || id === "new" ? ( </Typography>
<>
<div className="admin-form-row"> {editing || isNew ? (
<FormField label="Číslo položky" error={errors.item_number}> <Box sx={{ display: "flex", flexDirection: "column", gap: 0 }}>
<input <Box
type="text" sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Číslo položky" error={errors.item_number}>
<TextField
value={form.item_number} value={form.item_number}
onChange={(e) => { onChange={(e) => {
updateForm("item_number", e.target.value); updateForm("item_number", e.target.value);
setErrors((prev) => ({ ...prev, item_number: "" })); setErrors((prev) => ({ ...prev, item_number: "" }));
}} }}
className="admin-form-input"
placeholder="Např. SKL-001" placeholder="Např. SKL-001"
fullWidth
/> />
</FormField> </Field>
<FormField label="Název" error={errors.name} required> <Field label="Název" required error={errors.name}>
<input <TextField
type="text"
value={form.name} value={form.name}
error={!!errors.name}
onChange={(e) => { onChange={(e) => {
updateForm("name", e.target.value); updateForm("name", e.target.value);
setErrors((prev) => ({ ...prev, name: "" })); setErrors((prev) => ({ ...prev, name: "" }));
}} }}
className="admin-form-input"
placeholder="Název položky" placeholder="Název položky"
aria-invalid={!!errors.name} fullWidth
/> />
</FormField> </Field>
</div> </Box>
<FormField label="Popis"> <Field label="Popis">
<textarea <TextField
multiline
minRows={3}
value={form.description} value={form.description}
onChange={(e) => updateForm("description", e.target.value)} onChange={(e) => updateForm("description", e.target.value)}
className="admin-form-input"
rows={3}
placeholder="Volitelný popis položky" placeholder="Volitelný popis položky"
fullWidth
/> />
</FormField> </Field>
<div className="admin-form-row"> <Box
<FormField label="Kategorie"> sx={{
<select display: "grid",
value={form.category_id} gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
onChange={(e) => gap: 2,
updateForm("category_id", e.target.value) }}
}
className="admin-form-select"
> >
<option value=""> Bez kategorie </option> <Field label="Kategorie">
<Select
value={form.category_id}
onChange={(v) => updateForm("category_id", v)}
>
<MenuItem value=""> Bez kategorie </MenuItem>
{categories.map((cat) => ( {categories.map((cat) => (
<option key={cat.id} value={cat.id}> <MenuItem key={cat.id} value={String(cat.id)}>
{cat.name} {cat.name}
</option> </MenuItem>
))} ))}
</select> </Select>
</FormField> </Field>
<FormField label="Jednotka" error={errors.unit} required> <Field label="Jednotka" required error={errors.unit}>
<input <TextField
type="text"
value={form.unit} value={form.unit}
error={!!errors.unit}
onChange={(e) => { onChange={(e) => {
updateForm("unit", e.target.value); updateForm("unit", e.target.value);
setErrors((prev) => ({ ...prev, unit: "" })); setErrors((prev) => ({ ...prev, unit: "" }));
}} }}
className="admin-form-input"
placeholder="ks, m, kg..." placeholder="ks, m, kg..."
aria-invalid={!!errors.unit} fullWidth
/> />
</FormField> </Field>
<FormField label="Minimální množství"> <Field
<input label="Minimální množství"
hint="Upozornění při poklesu pod tuto hranici"
>
<TextField
type="number" type="number"
inputMode="numeric" inputProps={{ inputMode: "numeric", min: 0 }}
value={form.min_quantity} value={form.min_quantity}
onChange={(e) => onChange={(e) => updateForm("min_quantity", e.target.value)}
updateForm("min_quantity", e.target.value)
}
className="admin-form-input"
placeholder="0" placeholder="0"
min="0" fullWidth
/> />
<small className="admin-form-hint"> </Field>
Upozornění při poklesu pod tuto hranici </Box>
</small> <Field label="Poznámky">
</FormField> <TextField
</div> multiline
<FormField label="Poznámky"> minRows={2}
<textarea
value={form.notes} value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)} onChange={(e) => updateForm("notes", e.target.value)}
className="admin-form-input"
rows={2}
placeholder="Volitelné poznámky" placeholder="Volitelné poznámky"
fullWidth
/> />
</FormField> </Field>
</> </Box>
) : ( ) : (
<> <Box
<div className="admin-form-row"> sx={{
<FormField label="Číslo položky"> display: "grid",
<div style={{ fontWeight: 500 }} className="admin-mono"> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
{/* Číslo položky */}
<Box>
<Typography variant="caption" color="text.secondary">
Číslo položky
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{item?.item_number || "—"} {item?.item_number || "—"}
</div> </Typography>
</FormField> </Box>
<FormField label="Název"> {/* Název */}
<div style={{ fontWeight: 500 }}>{item?.name || "—"}</div> <Box>
</FormField> <Typography variant="caption" color="text.secondary">
</div> Název
<FormField label="Popis"> </Typography>
<div>{item?.description || "—"}</div> <Typography variant="body2" sx={{ fontWeight: 500 }}>
</FormField> {item?.name || "—"}
<div className="admin-form-row"> </Typography>
<FormField label="Kategorie"> </Box>
<div>{item?.category?.name || "—"}</div> {/* Popis */}
</FormField> <Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<FormField label="Jednotka"> <Typography variant="caption" color="text.secondary">
<div>{item?.unit || "—"}</div> Popis
</FormField> </Typography>
<FormField label="Minimální množství"> <Typography variant="body2">
<div className="admin-mono"> {item?.description || "—"}
</Typography>
</Box>
{/* Kategorie */}
<Box>
<Typography variant="caption" color="text.secondary">
Kategorie
</Typography>
<Typography variant="body2">
{item?.category?.name || "—"}
</Typography>
</Box>
{/* Jednotka */}
<Box>
<Typography variant="caption" color="text.secondary">
Jednotka
</Typography>
<Typography variant="body2">{item?.unit || "—"}</Typography>
</Box>
{/* Minimální množství */}
<Box>
<Typography variant="caption" color="text.secondary">
Minimální množství
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{item?.min_quantity != null ? item.min_quantity : "—"} {item?.min_quantity != null ? item.min_quantity : "—"}
</div> </Typography>
</FormField> </Box>
</div> {/* Poznámky */}
{item?.notes && ( {item?.notes && (
<FormField label="Poznámky"> <Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div> <Typography variant="caption" color="text.secondary">
</FormField> Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{item.notes}
</Typography>
</Box>
)} )}
</> </Box>
)} )}
</div> </Card>
</div>
</motion.div> </motion.div>
{/* Stock info — only in view mode, for existing items */} {/* Stock info — only in view mode, for existing items */}
{id !== "new" && !editing && item && ( {id !== "new" && !editing && item && (
<motion.div <motion.div
className="admin-card"
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.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
<div className="admin-card-body"> <Box
<h3 className="admin-card-title">Skladové zásoby</h3> sx={{
<div className="admin-kpi-grid admin-kpi-4"> display: "grid",
<div className="admin-stat-card info"> gridTemplateColumns: {
<div className="admin-stat-label">Celkové množství</div> xs: "1fr 1fr",
<div className="admin-stat-value admin-mono"> md: "1fr 1fr 1fr 1fr",
{item.total_quantity ?? 0} {item.unit} },
</div> gap: 2,
</div> mb: 3,
<div className="admin-stat-card success"> }}
<div className="admin-stat-label">K dispozici</div>
<div className="admin-stat-value admin-mono">
{item.available_quantity ?? 0} {item.unit}
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Hodnota zásob</div>
<div className="admin-stat-value admin-mono">
{item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč"}
</div>
</div>
<div
className={`admin-stat-card ${item.below_minimum ? "danger" : "info"}`}
> >
<div className="admin-stat-label">Pod minimem</div> <StatCard
<div className="admin-stat-value admin-mono"> label="Celkové množství"
{item.below_minimum ? "Ano" : "Ne"} value={`${item.total_quantity ?? 0} ${item.unit}`}
</div> color="info"
</div> />
</div> <StatCard
</div> label="K dispozici"
value={`${item.available_quantity ?? 0} ${item.unit}`}
color="success"
/>
<StatCard
label="Hodnota zásob"
value={
item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč"
}
color="warning"
/>
<StatCard
label="Pod minimem"
value={item.below_minimum ? "Ano" : "Ne"}
color={item.below_minimum ? "error" : "info"}
/>
</Box>
</motion.div> </motion.div>
)} )}
{/* Batches table — only in view mode, for existing items */} {/* Batches table — only in view mode, for existing items */}
{id !== "new" && !editing && ( {id !== "new" && !editing && (
<motion.div <motion.div
className="admin-card"
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.1 }} transition={{ duration: 0.25, delay: 0.1 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Dávky (FIFO)</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
{batches.length === 0 ? ( Dávky (FIFO)
<div className="admin-empty-state">Zatím žádné dávky</div> </Typography>
) : ( <DataTable<Batch>
<div className="admin-table-responsive"> columns={batchColumns}
<table className="admin-table"> rows={batches}
<thead> rowKey={(b) => b.id}
<tr> empty={<EmptyState title="Zatím žádné dávky" />}
<th>Datum příjmu</th> />
<th>Původní množství</th> </Card>
<th>Zůstatek</th>
<th>Cena za jednotku</th>
<th>Hodnota</th>
</tr>
</thead>
<tbody>
{batches.map((batch) => (
<tr key={batch.id}>
<td className="admin-mono">
{formatDate(batch.received_at)}
</td>
<td className="admin-mono">
{Number(batch.original_qty)}
</td>
<td className="admin-mono fw-500">
{Number(batch.quantity)}
</td>
<td className="admin-mono">
{formatCurrency(Number(batch.unit_price), "CZK")}
</td>
<td className="admin-mono">
{formatCurrency(
Number(batch.quantity) * Number(batch.unit_price),
"CZK",
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div> </motion.div>
)} )}
{/* Locations table — only in view mode, for existing items */} {/* Locations table — only in view mode, for existing items */}
{id !== "new" && !editing && ( {id !== "new" && !editing && (
<motion.div <motion.div
className="admin-card"
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.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Umístění</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
{locations.length === 0 ? ( Umístění
<div className="admin-empty-state">Zatím žádná umístění</div> </Typography>
) : ( <DataTable<ItemLocation>
<div className="admin-table-responsive"> columns={locationColumns}
<table className="admin-table"> rows={locations}
<thead> rowKey={(l) => l.id}
<tr> empty={<EmptyState title="Zatím žádná umístění" />}
<th>Kód</th> />
<th>Název</th> </Card>
<th>Množství</th>
</tr>
</thead>
<tbody>
{locations.map((loc) => (
<tr key={loc.id}>
<td className="admin-mono fw-500">
{loc.location?.code || "—"}
</td>
<td>{loc.location?.name || "—"}</td>
<td className="admin-mono fw-500">
{Number(loc.quantity)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div> </motion.div>
)} )}
</div>
{/* Delete confirmation */}
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat položku"
message={`Opravdu chcete smazat položku „${item?.name ?? ""}"?`}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</Box>
); );
} }