Files
app/src/admin/pages/WarehouseItemDetail.tsx
BOHA c228d7a8ca feat(mui): migrate WarehouseItemDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:56:36 +02:00

681 lines
20 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
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 useModalLock from "../hooks/useModalLock";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseItemDetailOptions,
warehouseCategoryListOptions,
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;
quantity: number;
original_qty: number;
unit_price: number;
received_at: string | null;
is_consumed: boolean;
}
interface ItemLocation {
id: number;
quantity: number;
location: {
id: number;
code: string;
name: string;
} | null;
}
interface ItemDetail extends WarehouseItem {
batches?: Batch[];
item_locations?: ItemLocation[];
}
const API_BASE = "/api/admin/warehouse/items";
interface ItemForm {
item_number: string;
name: string;
description: string;
category_id: string;
unit: string;
min_quantity: 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() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<ItemForm>({
item_number: "",
name: "",
description: "",
category_id: "",
unit: "ks",
min_quantity: "",
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const isNew = id === "new";
const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew));
const item = itemQuery.data as ItemDetail | undefined;
const isPending = itemQuery.isPending;
const { data: categories = [] } = useQuery(warehouseCategoryListOptions());
const formInitialized = useRef(false);
useEffect(() => {
formInitialized.current = false;
}, [id]);
useEffect(() => {
if (item && !formInitialized.current) {
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 || "",
});
formInitialized.current = true;
}
}, [item]);
useEffect(() => {
if (itemQuery.error && !isNew) {
alert.error("Položka nenalezena");
navigate("/warehouse/items");
}
}, [itemQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useModalLock(editing);
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canManage = hasPermission("warehouse.manage");
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
setForm((prev) => ({ ...prev, [field]: value }));
const saveMutation = useApiMutation<
Record<string, unknown>,
{ id?: number; message?: string }
>({
url: () => (isNew ? API_BASE : `${API_BASE}/${id}`),
method: () => (isNew ? "POST" : "PUT"),
invalidate: ["warehouse"],
onSuccess: (data) => {
setEditing(false);
alert.success(data?.message || "Položka byla uložena");
if (isNew && data?.id) {
navigate(`/warehouse/items/${data.id}`, { replace: true });
}
},
});
const deleteMutation = useApiMutation<void, void>({
url: () => `${API_BASE}/${id ?? ""}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: () => {
navigate("/warehouse/items");
setTimeout(() => alert.success("Položka byla deaktivována"), 300);
},
});
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
if (!form.unit.trim()) newErrors.unit = "Zadejte jednotku";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
const payload: Record<string, unknown> = {
item_number: form.item_number.trim() || null,
name: form.name.trim(),
description: form.description.trim() || null,
category_id: form.category_id ? Number(form.category_id) : null,
unit: form.unit.trim(),
min_quantity: form.min_quantity ? Number(form.min_quantity) : null,
notes: form.notes.trim() || null,
};
try {
await saveMutation.mutateAsync(payload);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!item) return;
try {
await deleteMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const saving = saveMutation.isPending;
if (isPending && !isNew) {
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 (
<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>
{/* Basic info */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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
/>
</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
/>
</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
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<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
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<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
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<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>
)}
{/* 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>
);
}