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 { 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 { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import { formatCurrency, formatDate } from "../utils/formatters";
@@ -15,6 +17,20 @@ import {
type WarehouseItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Field,
TextField,
Select,
StatCard,
EmptyState,
LoadingState,
PageHeader,
ConfirmDialog,
type DataColumn,
} from "../ui";
interface Batch {
id: number;
@@ -52,6 +68,35 @@ interface ItemForm {
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() {
const { id } = useParams();
const alert = useAlert();
@@ -69,6 +114,7 @@ export default function WarehouseItemDetail() {
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const isNew = id === "new";
const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew));
@@ -176,425 +222,459 @@ export default function WarehouseItemDetail() {
const saving = saveMutation.isPending;
if (isPending && !isNew) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const batches: Batch[] = item?.batches ?? [];
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 (
<div>
<Box>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
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 ? (
<>
{!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>
)}
<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>
{/* Basic info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
{editing || id === "new" ? (
<>
<div className="admin-form-row">
<FormField label="Číslo položky" error={errors.item_number}>
<input
type="text"
value={form.item_number}
onChange={(e) => {
updateForm("item_number", e.target.value);
setErrors((prev) => ({ ...prev, item_number: "" }));
}}
className="admin-form-input"
placeholder="Např. SKL-001"
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
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"
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
{editing || isNew ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0 }}>
<Box
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}
onChange={(e) => {
updateForm("item_number", e.target.value);
setErrors((prev) => ({ ...prev, item_number: "" }));
}}
placeholder="Např. SKL-001"
fullWidth
/>
</FormField>
<div className="admin-form-row">
<FormField label="Kategorie">
<select
value={form.category_id}
onChange={(e) =>
updateForm("category_id", e.target.value)
}
className="admin-form-select"
>
<option value=""> Bez kategorie </option>
{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"
</Field>
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
error={!!errors.name}
onChange={(e) => {
updateForm("name", e.target.value);
setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="Název položky"
fullWidth
/>
</FormField>
</>
) : (
<>
<div className="admin-form-row">
<FormField label="Číslo položky">
<div style={{ fontWeight: 500 }} className="admin-mono">
{item?.item_number || "—"}
</div>
</FormField>
<FormField label="Název">
<div style={{ fontWeight: 500 }}>{item?.name || "—"}</div>
</FormField>
</div>
<FormField label="Popis">
<div>{item?.description || "—"}</div>
</FormField>
<div className="admin-form-row">
<FormField label="Kategorie">
<div>{item?.category?.name || "—"}</div>
</FormField>
<FormField label="Jednotka">
<div>{item?.unit || "—"}</div>
</FormField>
<FormField label="Minimální množství">
<div className="admin-mono">
{item?.min_quantity != null ? item.min_quantity : "—"}
</div>
</FormField>
</div>
{item?.notes && (
<FormField label="Poznámky">
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div>
</FormField>
)}
</>
)}
</div>
</div>
</Field>
</Box>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={form.description}
onChange={(e) => updateForm("description", e.target.value)}
placeholder="Volitelný popis položky"
fullWidth
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Kategorie">
<Select
value={form.category_id}
onChange={(v) => updateForm("category_id", v)}
>
<MenuItem value=""> Bez kategorie </MenuItem>
{categories.map((cat) => (
<MenuItem key={cat.id} value={String(cat.id)}>
{cat.name}
</MenuItem>
))}
</Select>
</Field>
<Field label="Jednotka" required error={errors.unit}>
<TextField
value={form.unit}
error={!!errors.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
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>
{/* Stock info — only in view mode, for existing items */}
{id !== "new" && !editing && item && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Skladové zásoby</h3>
<div className="admin-kpi-grid admin-kpi-4">
<div className="admin-stat-card info">
<div className="admin-stat-label">Celkové množství</div>
<div className="admin-stat-value admin-mono">
{item.total_quantity ?? 0} {item.unit}
</div>
</div>
<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>
<div className="admin-stat-value admin-mono">
{item.below_minimum ? "Ano" : "Ne"}
</div>
</div>
</div>
</div>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr 1fr",
md: "1fr 1fr 1fr 1fr",
},
gap: 2,
mb: 3,
}}
>
<StatCard
label="Celkové množství"
value={`${item.total_quantity ?? 0} ${item.unit}`}
color="info"
/>
<StatCard
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>
)}
{/* Batches table — only in view mode, for existing items */}
{id !== "new" && !editing && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Dávky (FIFO)</h3>
{batches.length === 0 ? (
<div className="admin-empty-state">Zatím žádné dávky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum příjmu</th>
<th>Původní množství</th>
<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>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Dávky (FIFO)
</Typography>
<DataTable<Batch>
columns={batchColumns}
rows={batches}
rowKey={(b) => b.id}
empty={<EmptyState title="Zatím žádné dávky" />}
/>
</Card>
</motion.div>
)}
{/* Locations table — only in view mode, for existing items */}
{id !== "new" && !editing && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Umístění</h3>
{locations.length === 0 ? (
<div className="admin-empty-state">Zatím žádná umístění</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Kód</th>
<th>Název</th>
<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>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Umístění
</Typography>
<DataTable<ItemLocation>
columns={locationColumns}
rows={locations}
rowKey={(l) => l.id}
empty={<EmptyState title="Zatím žádná umístění" />}
/>
</Card>
</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>
);
}