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 = (
);
const RemoveIcon = (
);
const PlusIcon = (
);
/** 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(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
{response && response.reserved_quantity > 0 && (
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
)}
);
}
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({
project_id: prefill?.projectId ?? null,
notes: "",
});
const [items, setItems] = useState(() =>
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>({});
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]);
const saveMutation = useApiMutation<
ReturnType,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const confirmMutation = useApiMutation({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.operate")) return ;
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 = {};
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 saveIssue = async (): Promise => {
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) => {
try {
await confirmMutation.mutateAsync(issueId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
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 ;
}
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 (
{/* Header */}
{BackIcon}
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
{/* Header fields */}
Základní údaje
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
{/* Lines */}
Položky
{errors.items && (
{errors.items}
)}
{items.map((item, index) => (
updateItem(index, "item_id", id)}
/>
{
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
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í"
/>
))}
);
}