Files
app/src/admin/pages/WarehouseIssueForm.tsx
BOHA 39fe84ce99 feat(mui): consistent staggered page entrance across all 43 route pages
Adopt the PageEnter wrapper as every page's outermost render element and remove the ad-hoc per-page entrance motion.div wrappers. Every page now enters the same way — all top-level sections rise+fade in, staggered — so nothing appears instantly and the motion is identical app-wide. Presentation-only: no data/logic/hooks/invalidate/permissions touched. Embedded sub-tabs (CompanySettings, ReceivedInvoices), Login (auth shell) and the dev UiKit are intentionally excluded. Gates: tsc -b --noEmit=0, build=0, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:13:06 +02:00

610 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useId } from "react";
import {
useParams,
useNavigate,
useLocation,
Link as RouterLink,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
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 ItemPicker from "../components/warehouse/ItemPicker";
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,
PageEnter,
} 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();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
reservationId?: number;
itemId?: number;
projectId?: number;
quantity?: number;
itemName?: string;
} | null;
const isEdit = id !== undefined && id !== "new";
const [form, setForm] = useState<IssueForm>({
project_id: prefill?.projectId ?? null,
notes: "",
});
const [items, setItems] = useState<MovementItem[]>(() =>
isEdit
? []
: prefill?.itemId
? [
{
key: nextKey(),
item_id: prefill.itemId,
item_name: prefill.itemName,
quantity: prefill.quantity ?? 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: prefill.reservationId ?? null,
notes: null,
},
]
: [
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formInitialized = useRef(false);
const { data: issue, isPending } = useQuery(
warehouseIssueDetailOptions(isEdit ? id : undefined),
);
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
const { data: locations } = useQuery(warehouseLocationListOptions());
useEffect(() => {
formInitialized.current = false;
}, [id]);
useEffect(() => {
if (isEdit && issue && !formInitialized.current) {
setForm({
project_id: issue.project_id,
notes: issue.notes || "",
});
if (issue.items && issue.items.length > 0) {
setItems(
issue.items.map((item) => ({
key: nextKey(),
item_id: item.item_id,
item_name: item.item?.name,
quantity: Number(item.quantity),
unit_price: item.batch ? Number(item.batch.unit_price) : 0,
location_id: item.location_id,
batch_id: item.batch_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
);
}
formInitialized.current = true;
}
}, [isEdit, issue]);
if (!hasPermission("warehouse.operate")) return <Forbidden />;
const addItem = () => {
setItems((prev) => [
...prev,
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
]);
};
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) {
newErrors.project_id = "Projekt je povinný";
}
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
newErrors.items = "Přidejte alespoň jednu položku";
}
for (const item of validItems) {
if (item.quantity <= 0) {
newErrors.items = "Množství musí být větší než 0";
break;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const buildPayload = () => ({
project_id: form.project_id,
notes: form.notes.trim() || null,
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
batch_id: l.batch_id,
quantity: l.quantity,
location_id: l.location_id,
reservation_id: l.reservation_id,
notes: l.notes?.trim() || null,
})),
});
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
const confirmMutation = useApiMutation<void, void>({
url: () =>
confirmIssueId
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const saveIssue = async (): Promise<number | null> => {
try {
const data = await saveMutation.mutateAsync(buildPayload());
return data?.id ?? Number(id);
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit výdejku",
);
return null;
}
};
const confirmIssue = async (issueId: number) => {
setConfirmIssueId(issueId);
try {
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setConfirmIssueId(null);
}
};
const handleSaveDraft = async () => {
if (!validate()) return;
setSaving(true);
try {
const savedId = await saveIssue();
if (savedId) {
alert.success("Výdejka byla uložena jako návrh");
navigate(`/warehouse/issues/${savedId}`);
}
} finally {
setSaving(false);
}
};
const handleSaveAndConfirm = async () => {
if (!validate()) return;
setSaving(true);
try {
const savedId = await saveIssue();
if (savedId) {
await confirmIssue(savedId);
navigate(`/warehouse/issues/${savedId}`);
}
} finally {
setSaving(false);
}
};
if (isEdit && isPending) {
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 (
<PageEnter>
{/* Header */}
<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"
>
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
</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>
{/* Header fields */}
<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>
{/* Lines */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<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>
</PageEnter>
);
}