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,425 +222,459 @@ 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 ?? [];
const title = isNew ? "Nová položka" : item ? item.name : "Detail položky";
const subtitle = item?.item_number || undefined;
// Edit / view mode action buttons
const headerActions = canManage ? (
<Box sx={{ display: "flex", gap: 1 }}>
{editing || isNew ? (
<>
{!isNew && (
<Button
variant="outlined"
color="inherit"
onClick={() => {
setEditing(false);
setErrors({});
if (item) {
setForm({
item_number: item.item_number || "",
name: item.name,
description: item.description || "",
category_id: item.category_id
? String(item.category_id)
: "",
unit: item.unit,
min_quantity:
item.min_quantity != null
? String(item.min_quantity)
: "",
notes: item.notes || "",
});
}
}}
disabled={saving}
>
Zrušit
</Button>
)}
<Button onClick={handleSave} disabled={saving}>
{saving ? "Ukládání..." : isNew ? "Vytvořit" : "Uložit"}
</Button>
</>
) : (
<>
{item && (
<Button
variant="outlined"
color="error"
onClick={() => setShowDeleteConfirm(true)}
>
Smazat
</Button>
)}
<Button startIcon={EditIcon} onClick={() => setEditing(true)}>
Upravit
</Button>
</>
)}
</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 ( return (
<div> <Box>
{/* Header */} {/* Header */}
<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 style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <PageHeader
<Link title={title}
to="/warehouse/items" subtitle={subtitle}
className="admin-btn-icon" actions={
title="Zpět na seznam" <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
aria-label="Zpět na seznam" <Button
> component={RouterLink}
<svg to="/warehouse/items"
width="20" variant="outlined"
height="20" color="inherit"
viewBox="0 0 24 24" startIcon={BackIcon}
fill="none" >
stroke="currentColor" Zpět
strokeWidth="2" </Button>
> {headerActions}
<path d="M19 12H5M12 19l-7-7 7-7" /> </Box>
</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 ? (
<>
{!isNew && (
<button
onClick={() => {
setEditing(false);
setErrors({});
if (item) {
setForm({
item_number: item.item_number || "",
name: item.name,
description: item.description || "",
category_id: item.category_id
? String(item.category_id)
: "",
unit: item.unit,
min_quantity:
item.min_quantity != null
? String(item.min_quantity)
: "",
notes: item.notes || "",
});
}
}}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
)}
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : isNew ? (
"Vytvořit"
) : (
"Uložit"
)}
</button>
</>
) : (
<>
{item && (
<button
onClick={handleDelete}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Smazat
</button>
)}
<button
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
</button>
</>
)}
</div>
)}
</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={{
value={form.item_number} display: "grid",
onChange={(e) => { gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
updateForm("item_number", e.target.value); gap: 2,
setErrors((prev) => ({ ...prev, item_number: "" })); }}
}} >
className="admin-form-input" <Field label="Číslo položky" error={errors.item_number}>
placeholder="Např. SKL-001" <TextField
/> value={form.item_number}
</FormField> onChange={(e) => {
<FormField label="Název" error={errors.name} required> updateForm("item_number", e.target.value);
<input setErrors((prev) => ({ ...prev, item_number: "" }));
type="text" }}
value={form.name} placeholder="Např. SKL-001"
onChange={(e) => { fullWidth
updateForm("name", e.target.value);
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název položky"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) => updateForm("description", e.target.value)}
className="admin-form-input"
rows={3}
placeholder="Volitelný popis položky"
/> />
</FormField> </Field>
<div className="admin-form-row"> <Field label="Název" required error={errors.name}>
<FormField label="Kategorie"> <TextField
<select value={form.name}
value={form.category_id} error={!!errors.name}
onChange={(e) => onChange={(e) => {
updateForm("category_id", e.target.value) updateForm("name", e.target.value);
} setErrors((prev) => ({ ...prev, name: "" }));
className="admin-form-select" }}
> placeholder="Název položky"
<option value=""> Bez kategorie </option> fullWidth
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</FormField>
<FormField label="Jednotka" error={errors.unit} required>
<input
type="text"
value={form.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
setErrors((prev) => ({ ...prev, unit: "" }));
}}
className="admin-form-input"
placeholder="ks, m, kg..."
aria-invalid={!!errors.unit}
/>
</FormField>
<FormField label="Minimální množství">
<input
type="number"
inputMode="numeric"
value={form.min_quantity}
onChange={(e) =>
updateForm("min_quantity", e.target.value)
}
className="admin-form-input"
placeholder="0"
min="0"
/>
<small className="admin-form-hint">
Upozornění při poklesu pod tuto hranici
</small>
</FormField>
</div>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
className="admin-form-input"
rows={2}
placeholder="Volitelné poznámky"
/> />
</FormField> </Field>
</> </Box>
) : ( <Field label="Popis">
<> <TextField
<div className="admin-form-row"> multiline
<FormField label="Číslo položky"> minRows={3}
<div style={{ fontWeight: 500 }} className="admin-mono"> value={form.description}
{item?.item_number || "—"} onChange={(e) => updateForm("description", e.target.value)}
</div> placeholder="Volitelný popis položky"
</FormField> fullWidth
<FormField label="Název"> />
<div style={{ fontWeight: 500 }}>{item?.name || "—"}</div> </Field>
</FormField> <Box
</div> sx={{
<FormField label="Popis"> display: "grid",
<div>{item?.description || "—"}</div> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
</FormField> gap: 2,
<div className="admin-form-row"> }}
<FormField label="Kategorie"> >
<div>{item?.category?.name || "—"}</div> <Field label="Kategorie">
</FormField> <Select
<FormField label="Jednotka"> value={form.category_id}
<div>{item?.unit || "—"}</div> onChange={(v) => updateForm("category_id", v)}
</FormField> >
<FormField label="Minimální množství"> <MenuItem value=""> Bez kategorie </MenuItem>
<div className="admin-mono"> {categories.map((cat) => (
{item?.min_quantity != null ? item.min_quantity : "—"} <MenuItem key={cat.id} value={String(cat.id)}>
</div> {cat.name}
</FormField> </MenuItem>
</div> ))}
{item?.notes && ( </Select>
<FormField label="Poznámky"> </Field>
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div> <Field label="Jednotka" required error={errors.unit}>
</FormField> <TextField
)} value={form.unit}
</> error={!!errors.unit}
)} onChange={(e) => {
</div> updateForm("unit", e.target.value);
</div> setErrors((prev) => ({ ...prev, unit: "" }));
}}
placeholder="ks, m, kg..."
fullWidth
/>
</Field>
<Field
label="Minimální množství"
hint="Upozornění při poklesu pod tuto hranici"
>
<TextField
type="number"
inputProps={{ inputMode: "numeric", min: 0 }}
value={form.min_quantity}
onChange={(e) => updateForm("min_quantity", e.target.value)}
placeholder="0"
fullWidth
/>
</Field>
</Box>
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
placeholder="Volitelné poznámky"
fullWidth
/>
</Field>
</Box>
) : (
<Box
sx={{
display: "grid",
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 || "—"}
</Typography>
</Box>
{/* Název */}
<Box>
<Typography variant="caption" color="text.secondary">
Název
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item?.name || "—"}
</Typography>
</Box>
{/* Popis */}
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Popis
</Typography>
<Typography variant="body2">
{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 : "—"}
</Typography>
</Box>
{/* Poznámky */}
{item?.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{item.notes}
</Typography>
</Box>
)}
</Box>
)}
</Card>
</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"> <StatCard
{item.available_quantity ?? 0} {item.unit} label="Celkové množství"
</div> value={`${item.total_quantity ?? 0} ${item.unit}`}
</div> color="info"
<div className="admin-stat-card warning"> />
<div className="admin-stat-label">Hodnota zásob</div> <StatCard
<div className="admin-stat-value admin-mono"> label="K dispozici"
{item.stock_value != null value={`${item.available_quantity ?? 0} ${item.unit}`}
? formatCurrency(item.stock_value, "CZK") color="success"
: "0,00 Kč"} />
</div> <StatCard
</div> label="Hodnota zásob"
<div value={
className={`admin-stat-card ${item.below_minimum ? "danger" : "info"}`} item.stock_value != null
> ? formatCurrency(item.stock_value, "CZK")
<div className="admin-stat-label">Pod minimem</div> : "0,00 Kč"
<div className="admin-stat-value admin-mono"> }
{item.below_minimum ? "Ano" : "Ne"} color="warning"
</div> />
</div> <StatCard
</div> label="Pod minimem"
</div> 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>
); );
} }