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 { 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 { 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 { 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 ItemPicker from "../components/warehouse/ItemPicker";
import FormField from "../components/FormField";
import WarehouseMovementTable, {
type MovementItem,
} from "../components/warehouse/WarehouseMovementTable";
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 { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { Button, Card, TextField, Select, Field, LoadingState } from "../ui";
interface IssueForm { interface IssueForm {
project_id: number | null; project_id: number | null;
notes: string; 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() { export default function WarehouseIssueForm() {
const { id } = useParams(); const { id } = useParams();
const alert = useAlert(); const alert = useAlert();
@@ -87,6 +226,8 @@ export default function WarehouseIssueForm() {
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 })); const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? []; const projects = projectsData?.data ?? [];
const { data: locations } = useQuery(warehouseLocationListOptions());
useEffect(() => { useEffect(() => {
formInitialized.current = false; formInitialized.current = false;
}, [id]); }, [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 validate = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.project_id) { if (!form.project_id) {
@@ -245,138 +402,219 @@ export default function WarehouseIssueForm() {
}; };
if (isEdit && isPending) { if (isEdit && isPending) {
return ( return <LoadingState />;
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
} }
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 ( 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" }}> <Box
<Link sx={{
to="/warehouse/issues" display: "flex",
className="admin-btn-icon" alignItems: "flex-start",
title="Zpět na seznam" justifyContent: "space-between",
aria-label="Zpět na seznam" flexWrap: "wrap",
> gap: 2,
<svg mb: 3,
width="20" }}
height="20" >
viewBox="0 0 24 24" <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
fill="none" <IconButton
stroke="currentColor" component={RouterLink}
strokeWidth="2" to="/warehouse/issues"
title="Zpět na seznam"
aria-label="Zpět na seznam"
> >
<path d="M19 12H5M12 19l-7-7 7-7" /> {BackIcon}
</svg> </IconButton>
</Link> <Typography variant="h4">
<div>
<h1 className="admin-page-title">
{isEdit ? "Upravit výdejku" : "Nová výdejka"} {isEdit ? "Upravit výdejku" : "Nová výdejka"}
</h1> </Typography>
</div> </Box>
</div> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<div className="admin-page-actions"> <Button
<button variant="outlined"
type="button" color="inherit"
onClick={handleSaveDraft} onClick={handleSaveDraft}
className="admin-btn admin-btn-secondary" disabled={saving}
disabled={saving} >
> {saving ? "Ukládání..." : "Uložit jako návrh"}
{saving ? "Ukládání..." : "Uložit jako návrh"} </Button>
</button> <Button onClick={handleSaveAndConfirm} disabled={saving}>
<button {saving ? "Ukládání..." : "Uložit a potvrdit"}
type="button" </Button>
onClick={handleSaveAndConfirm} </Box>
className="admin-btn admin-btn-primary" </Box>
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</button>
</div>
</motion.div> </motion.div>
{/* Header fields */} {/* Header fields */}
<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
<FormField label="Projekt"> </Typography>
<select <Field label="Projekt" error={errors.project_id}>
value={form.project_id ?? ""} <Select
onChange={(e) => value={form.project_id === null ? "" : String(form.project_id)}
setForm((prev) => ({ onChange={(val) =>
...prev, setForm((prev) => ({
project_id: ...prev,
e.target.value === "" ? null : Number(e.target.value), project_id: val === "" ? null : Number(val),
})) }))
} }
className="admin-form-select" options={projectOptions}
> />
<option value="">Vyberte projekt...</option> </Field>
{projects.map((p: Project) => ( <Field label="Poznámky">
<option key={p.id} value={p.id}> <TextField
{p.project_number} {p.name} multiline
</option> minRows={3}
))} value={form.notes}
</select> onChange={(e) =>
{errors.project_id && ( setForm((prev) => ({ ...prev, notes: e.target.value }))
<div className="admin-form-error">{errors.project_id}</div> }
)} placeholder="Volitelné poznámky"
</FormField> />
<FormField label="Poznámky"> </Field>
<textarea </Card>
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>
</motion.div> </motion.div>
{/* Lines */} {/* Lines */}
<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"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Položky</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && ( {errors.items && (
<div <Typography
className="admin-form-error" variant="caption"
style={{ marginBottom: "0.75rem" }} color="error"
sx={{ display: "block", mb: 1.5 }}
> >
{errors.items} {errors.items}
</div> </Typography>
)} )}
<WarehouseMovementTable <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
items={items} {items.map((item, index) => (
onChange={setItems} <Box
mode="issue" key={item.key}
/> sx={{
</div> 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> </motion.div>
</div> </Box>
); );
} }