feat(mui): migrate WarehouseIssueForm onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 08:06:28 +02:00
parent 601db41a09
commit d46827dfdf

View File

@@ -1,24 +1,163 @@
import { useState, useEffect, useRef, useId } from "react";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import {
useParams,
useNavigate,
useLocation,
Link as RouterLink,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
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 WarehouseMovementTable, {
type MovementItem,
} from "../components/warehouse/WarehouseMovementTable";
import ItemPicker from "../components/warehouse/ItemPicker";
import { warehouseIssueDetailOptions } from "../lib/queries/warehouse";
import { jsonQuery } from "../lib/apiAdapter";
import {
warehouseIssueDetailOptions,
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations";
import { Button, Card, TextField, Select, Field, LoadingState } from "../ui";
interface IssueForm {
project_id: number | null;
notes: string;
}
interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
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>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const PlusIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
/** Inline batch dropdown: lists FIFO batches for an item, sets batch + unit price. */
function BatchSelect({
itemId,
value,
onChange,
}: {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<Box>
{response && response.reserved_quantity > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</Typography>
)}
<Select
value={value === null ? "" : String(value)}
onChange={(val) => {
const batchId = Number(val);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
options={[
{ value: "", label: "-- auto FIFO --" },
...batches.map((b) => ({
value: String(b.id),
label: `${new Date(b.received_at).toLocaleDateString("cs-CZ")} | ${b.quantity} ks | ${Number(b.unit_price).toFixed(2)}`,
})),
]}
/>
</Box>
);
}
export default function WarehouseIssueForm() {
const { id } = useParams();
const alert = useAlert();
@@ -87,6 +226,8 @@ export default function WarehouseIssueForm() {
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
const { data: locations } = useQuery(warehouseLocationListOptions());
useEffect(() => {
formInitialized.current = false;
}, [id]);
@@ -134,6 +275,22 @@ export default function WarehouseIssueForm() {
]);
};
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof MovementItem,
value: unknown,
) => {
setItems((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.project_id) {
@@ -245,138 +402,219 @@ export default function WarehouseIssueForm() {
};
if (isEdit && isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const projectOptions = [
{ value: "", label: "Vyberte projekt..." },
...projects.map((p: Project) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
];
const locationOptions = [
{ value: "", label: "-- bez lokace --" },
...(locations ?? []).map((loc: WarehouseLocation) => ({
value: String(loc.id),
label: `${loc.code} - ${loc.name}`,
})),
];
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/issues"
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"
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/issues"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
</h1>
</div>
</div>
<div className="admin-page-actions">
<button
type="button"
onClick={handleSaveDraft}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</button>
<button
type="button"
onClick={handleSaveAndConfirm}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</button>
</div>
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveDraft}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</Button>
<Button onClick={handleSaveAndConfirm} disabled={saving}>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</Button>
</Box>
</Box>
</motion.div>
{/* Header fields */}
<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">
<FormField label="Projekt">
<select
value={form.project_id ?? ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
project_id:
e.target.value === "" ? null : Number(e.target.value),
}))
}
className="admin-form-select"
>
<option value="">Vyberte projekt...</option>
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
{errors.project_id && (
<div className="admin-form-error">{errors.project_id}</div>
)}
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</div>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Projekt" error={errors.project_id}>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val === "" ? null : Number(val),
}))
}
options={projectOptions}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Card>
</motion.div>
{/* Lines */}
<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">Položky</h3>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<div
className="admin-form-error"
style={{ marginBottom: "0.75rem" }}
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</div>
</Typography>
)}
<WarehouseMovementTable
items={items}
onChange={setItems}
mode="issue"
/>
</div>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<BatchSelect
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
disabled
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
</motion.div>
</div>
</Box>
);
}