import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useNavigate, useParams, 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 useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { restrictToVerticalAxis, restrictToParentElement, } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { useApiMutation } from "../lib/queries/mutations"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; import apiFetch from "../utils/api"; import { jsonQuery } from "../lib/apiAdapter"; import { issuedOrderDetailOptions, issuedOrderSuppliersOptions, type Supplier, } from "../lib/queries/issued-orders"; import { companySettingsOptions } from "../lib/queries/settings"; import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, CheckboxField, ConfirmDialog, LoadingState, PageEnter, SupplierPicker, headerActionsSx, } from "../ui"; import { ISSUED_ORDER_STATUS, statusLabel, statusColor, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // The live (finalized) status for an issued order — assigning it (on create or // on finalizing a draft) makes the backend consume the official PO number. const LIVE_STATUS = "sent"; // Labels for the buttons that trigger a status transition. const TRANSITION_LABELS: Record = { sent: "Odeslat", confirmed: "Potvrdit", completed: "Dokončit", cancelled: "Stornovat", }; const VAT_OPTIONS = [0, 10, 12, 15, 21].map((v) => ({ value: String(v), label: `${v}%`, })); const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"]; const BackIcon = ( ); const FileIcon = ( ); const DragIcon = ( ); const RemoveIcon = ( ); interface OrderItem { id?: number; _key: string; description: string; item_description: string; // Held as the raw typed string while editing so the field can be cleared // (empty renders fine in a type="number" input). Coerced via Number(x) || 0 // only where used (live totals + save payload). vat_rate stays numeric: it // is edited via a Select, never a free-text number input. quantity: string | number; unit: string; unit_price: string | number; vat_rate: number; } interface OrderForm { supplier_id: number | null; supplier_name: string; currency: string; apply_vat: boolean; vat_rate: number; order_date: string; delivery_date: string; language: string; delivery_terms: string; payment_terms: string; issued_by: string; notes: string; internal_notes: string; status: string; } // Sortable line-item row (mirrors InvoiceDetail's SortableInvoiceRow). function SortableOrderRow({ item, index, currency, apply_vat, readOnly, onUpdate, onRemove, canDelete, }: { item: OrderItem; index: number; currency: string; apply_vat: boolean; readOnly: boolean; onUpdate: ( index: number, field: keyof OrderItem, value: string | number, ) => void; onRemove: (index: number) => void; canDelete: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item._key, disabled: readOnly }); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--mui-palette-action-hover)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); if (isMobile) { return ( {!readOnly && ( {DragIcon} )} Položka {index + 1} {!readOnly && canDelete && ( onRemove(index)} title="Odebrat" aria-label="Odebrat" > {RemoveIcon} )} onUpdate(index, "description", e.target.value)} placeholder="Popis položky..." InputProps={{ readOnly }} /> onUpdate(index, "item_description", e.target.value)} placeholder="Volitelný" InputProps={{ readOnly }} multiline rows={2} sx={{ "& textarea": { resize: "vertical" } }} /> onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} InputProps={{ readOnly }} /> onUpdate(index, "unit", e.target.value)} placeholder="ks" InputProps={{ readOnly }} /> onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} InputProps={{ readOnly }} /> {apply_vat && (readOnly ? ( ) : ( onUpdate(index, "vat_rate", Number(val))} sx={{ minWidth: "4.5rem" }} options={VAT_OPTIONS} /> )} ) : null} {formatCurrency(lineTotal, currency)} {!readOnly && canDelete && ( onRemove(index)} title="Odebrat" aria-label="Odebrat" > {RemoveIcon} )} ); } export default function IssuedOrderDetail() { const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const navigate = useNavigate(); const alert = useAlert(); const { hasPermission, user } = useAuth(); const keyCounterRef = useRef(1); const emptyItem = useCallback( (): OrderItem => ({ _key: `io-${++keyCounterRef.current}`, description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }), [], ); const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 }, }), useSensor(KeyboardSensor), ); const [form, setForm] = useState({ supplier_id: null, supplier_name: "", currency: "CZK", apply_vat: true, vat_rate: 21, order_date: todayLocalStr(), delivery_date: "", language: "cs", delivery_terms: "", payment_terms: "", issued_by: user?.fullName || "", notes: "", internal_notes: "", status: "draft", }); const [items, setItems] = useState([ { _key: "io-1", description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }, ]); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); // Which save action is in flight ("draft" | the live status | "save"), so // only the clicked button shows its spinner — `saving` still disables all of // them to prevent a concurrent double-submit. const [savingAction, setSavingAction] = useState(null); const [dataReady, setDataReady] = useState(false); const [poNumber, setPoNumber] = useState(""); const [statusChanging, setStatusChanging] = useState(null); const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null; }>({ show: false, status: null }); const [pdfLoading, setPdfLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); // ─── Queries ─── const suppliersQuery = useQuery(issuedOrderSuppliersOptions()); // The lookup returns ACTIVE suppliers only, but a saved order may reference // a since-deactivated one — without a fallback option the picker would // resolve to nothing and render the counterparty blank. The fallback is // built from the hydrated form state (supplier_name comes from the detail // response), so the name stays visible and a re-save keeps the same id. const suppliers = useMemo(() => { const activeSuppliers = suppliersQuery.data ?? []; if ( form.supplier_id != null && !activeSuppliers.some((s: Supplier) => s.id === form.supplier_id) ) { return [ ...activeSuppliers, { id: form.supplier_id, name: form.supplier_name || `Dodavatel #${form.supplier_id}`, ico: null, dic: null, address: null, email: null, phone: null, }, ]; } return activeSuppliers; }, [suppliersQuery.data, form.supplier_id, form.supplier_name]); const companySettings = useQuery(companySettingsOptions()).data; // Configurable currency list from company settings (falls back to the // built-in list when settings are empty) — matches Offers/Invoices. const currencyOptions = ( companySettings?.available_currencies || CURRENCY_FALLBACK ).map((c) => ({ value: c, label: c })); const detailQuery = useQuery(issuedOrderDetailOptions(id)); const detail = detailQuery.data ?? null; const nextNumberQuery = useQuery({ queryKey: ["issued-orders", "next-number"], queryFn: () => jsonQuery<{ next_number?: string; number?: string }>( `${API_BASE}/issued-orders/next-number`, ).then((d) => d?.next_number || d?.number || ""), enabled: !isEdit, }); // ─── Edit mode: hydrate form from detail (once) ─── useEffect(() => { if (!isEdit || dataReady) return; if (detailQuery.isLoading || suppliersQuery.isLoading) return; if (!detailQuery.data) return; const d = detailQuery.data; setForm({ supplier_id: d.supplier_id ?? null, supplier_name: d.supplier_name ?? "", currency: d.currency || "CZK", apply_vat: d.apply_vat !== false, vat_rate: numberOr(d.vat_rate, 21), order_date: normalizeDateStr(d.order_date), delivery_date: normalizeDateStr(d.delivery_date), language: d.language || "cs", delivery_terms: d.delivery_terms || "", payment_terms: d.payment_terms || "", issued_by: d.issued_by || "", notes: d.notes || "", internal_notes: d.internal_notes || "", status: d.status, }); setPoNumber(d.po_number || ""); const mapped = d.items && d.items.length > 0 ? d.items.map((it) => ({ _key: `io-${++keyCounterRef.current}`, id: it.id, description: it.description || "", item_description: it.item_description || "", quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)), })) : []; if (mapped.length > 0) setItems(mapped); setDataReady(true); }, [ isEdit, dataReady, detailQuery.isLoading, detailQuery.data, suppliersQuery.isLoading, ]); // ─── Create mode: set the previewed PO number + default issued_by ─── useEffect(() => { if (isEdit || dataReady) return; if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return; if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data); setDataReady(true); }, [ isEdit, dataReady, nextNumberQuery.isLoading, nextNumberQuery.data, suppliersQuery.isLoading, ]); // Keep the displayed PO number in sync once it becomes available — a draft // has none until it is finalized (draft → sent), at which point the // invalidated detail query refetches with the freshly-assigned number. The // one-shot hydration effect above won't re-run (dataReady stays true), so // mirror the latest non-empty po_number here. useEffect(() => { if (isEdit && detail?.po_number && detail.po_number !== poNumber) { setPoNumber(detail.po_number); } }, [isEdit, detail?.po_number, poNumber]); // Form is editable only in create mode or while draft/sent. const editable = !isEdit || form.status === "draft" || form.status === "sent"; const canExport = hasPermission("orders.view"); // ─── Totals (live) ─── const totals = useMemo(() => { let subtotal = 0; const vatByRate: Record = {}; items.forEach((it) => { const line = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); subtotal += line; if (form.apply_vat) { const rate = Number(it.vat_rate) || 0; vatByRate[rate] = (vatByRate[rate] || 0) + (line * rate) / 100; } }); const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0); return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }; }, [items, form.apply_vat]); // ─── Mutations ─── const saveMutation = useApiMutation, { id: number }>({ url: () => isEdit ? `${API_BASE}/issued-orders/${id}` : `${API_BASE}/issued-orders`, method: () => (isEdit ? "PUT" : "POST"), invalidate: ["issued-orders"], }); const statusMutation = useApiMutation<{ status: string }, { id: number }>({ url: () => `${API_BASE}/issued-orders/${id}`, method: () => "PUT", invalidate: ["issued-orders"], }); const deleteMutation = useApiMutation({ url: () => `${API_BASE}/issued-orders/${id}`, method: () => "DELETE", invalidate: ["issued-orders"], }); // ─── Item handlers ─── const updateItem = ( index: number, field: keyof OrderItem, value: string | number, ) => setItems((prev) => prev.map((it, i) => (i === index ? { ...it, [field]: value } : it)), ); const addItem = () => setItems((prev) => [...prev, emptyItem()]); const removeItem = (index: number) => { if (items.length <= 1) return; setItems((prev) => prev.filter((_, i) => i !== index)); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setItems((prev) => { const oldIndex = prev.findIndex((i) => i._key === String(active.id)); const newIndex = prev.findIndex((i) => i._key === String(over.id)); if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex); }); }; const selectSupplier = (id: number | null) => { const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null; setForm((prev) => ({ ...prev, supplier_id: id, supplier_name: s?.name || "", })); setErrors((prev) => ({ ...prev, supplier_id: "" })); }; // ─── Submit (create + edit) ─── const handleSubmit = async (targetStatus?: string) => { const newErrors: Record = {}; if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele"; if (!form.order_date) newErrors.order_date = "Zadejte datum"; if (items.length === 0 || items.every((i) => !i.description.trim())) { newErrors.items = "Přidejte alespoň jednu položku"; } setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); setSavingAction(targetStatus ?? "save"); try { const payload: Record = { supplier_id: form.supplier_id, currency: form.currency, vat_rate: form.vat_rate, apply_vat: form.apply_vat, order_date: form.order_date, delivery_date: form.delivery_date || null, language: form.language, delivery_terms: form.delivery_terms, payment_terms: form.payment_terms, issued_by: form.issued_by, notes: form.notes, internal_notes: form.internal_notes, items: items .filter((i) => i.description.trim()) .map((it, i) => ({ description: it.description, item_description: it.item_description, // Coerce the raw typed strings back to numbers so an empty/partial // field never reaches the server as NaN (mirrors the totals math). quantity: Number(it.quantity) || 0, unit: it.unit, unit_price: Number(it.unit_price) || 0, vat_rate: it.vat_rate, position: i, })), }; // Only set status when a target is given (create-as-draft / create-as-live // / finalize). Editing a live order sends no status — the backend keeps // its current status. The PO number is never user-editable. if (targetStatus) payload.status = targetStatus; const data = await saveMutation.mutateAsync(payload); alert.success( isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena", ); // Archive the PDF to NAS for any non-draft order (create-as-sent, // finalize draft→sent, or editing an already-live order). A draft has no // PO number, so it is skipped (the backend also guards on po_number). // Fire-and-forget: never block the success toast or navigation. const recordId = isEdit ? id : data.id; const effectiveStatus = targetStatus ?? form.status; if (recordId && effectiveStatus !== "draft") { apiFetch(`${API_BASE}/issued-orders-pdf/${recordId}?save=1`).catch( () => {}, ); } // Reflect the just-saved status locally. On CREATE the component is reused // for the redirect to the new order's detail and the one-shot edit-mode // hydration won't re-run (dataReady stays true), so without this the page // would show the stale create-mode status ("draft") even though the order // was created as "sent" — until a manual refresh. On finalize (edit // draft→live) it flips the form out of the draft button branch. The PO // number is repopulated by the detail-query sync effect. if (targetStatus) { setForm((prev) => ({ ...prev, status: targetStatus })); } if (!isEdit) navigate(`/orders/issued/${data.id}`); } catch (err) { alert.error( err instanceof Error ? err.message : isEdit ? "Nepodařilo se uložit objednávku" : "Nepodařilo se vytvořit objednávku", ); } finally { setSaving(false); setSavingAction(null); } }; // Native form submit (e.g. Enter): route to the right save path. Create → // finalize live; editing a draft → save draft (the visible primary button); // editing a live order → plain save (no status change). const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!isEdit) void handleSubmit(LIVE_STATUS); else if (form.status === "draft") void handleSubmit("draft"); else void handleSubmit(); }; // ─── Status transition ─── const handleStatusChange = async () => { if (!statusConfirm.status) return; const newStatus = statusConfirm.status; setStatusChanging(newStatus); setStatusConfirm({ show: false, status: null }); try { await statusMutation.mutateAsync({ status: newStatus }); alert.success("Stav byl změněn"); // Mirror the new status into the form (same as handleSubmit's success // path): the one-shot hydration won't re-run (dataReady stays true), so // without this the StatusChip, the `editable` flag and the delete-button // gate would keep reading the stale status until a full remount. The PO // number is repopulated by the detail-query sync effect. setForm((prev) => ({ ...prev, status: newStatus })); } catch (err) { alert.error(err instanceof Error ? err.message : "Chyba připojení"); } finally { setStatusChanging(null); } }; // ─── Delete ─── const handleDelete = async () => { setDeleting(true); try { await deleteMutation.mutateAsync(undefined); alert.success("Objednávka byla smazána"); navigate("/orders?tab=vydane"); } catch (err) { alert.error(err instanceof Error ? err.message : "Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; // ─── PDF export ─── const handleViewPdf = async () => { const newWindow = window.open("", "_blank"); setPdfLoading(true); try { const response = await apiFetch( `${API_BASE}/issued-orders-pdf/${id}?lang=${form.language}`, ); if (!response.ok) { newWindow?.close(); alert.error("PDF se nepodařilo vygenerovat"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; setTimeout(() => URL.revokeObjectURL(url), 60000); } catch { newWindow?.close(); alert.error("Chyba připojení"); } finally { setPdfLoading(false); } }; // ─── Permission guards (AFTER all hooks) ─── if (!isEdit && !hasPermission("orders.create")) return ; if (isEdit && !hasPermission("orders.view")) return ; if (isEdit && !detail && !detailQuery.isError) { return ; } if (!dataReady) { return ; } return ( {/* Header */} {isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"} {/* Edit mode: a draft has no PO number yet → show "Koncept". Create mode: show the previewed next-number when available. */} {(isEdit || poNumber) && ( {isEdit ? documentNumberLabel(poNumber) : poNumber} )} {isEdit && ( )} {isEdit && canExport && ( )} {/* ── Create mode: two-button save ── */} {!isEdit && ( <> )} {/* ── Edit mode, draft: save-draft + finalize (Odeslat). A draft is deleted rather than cancelled, so the generic transition buttons (sent/cancelled) are suppressed — the finalize button IS the draft → sent transition. ── */} {isEdit && form.status === "draft" && ( <> {hasPermission("orders.edit") && ( )} )} {/* ── Edit mode, live (non-draft) order — unchanged save + transitions ── */} {isEdit && form.status !== "draft" && ( <> {editable && ( )} {hasPermission("orders.edit") && detail?.valid_transitions?.map((status) => ( ))} )} {/* A completed order is terminal/finalized — never deletable from the UI. draft/sent/confirmed/cancelled may be deleted. (Backend rejection of deleting an order is a separate hardening, out of scope here.) */} {isEdit && hasPermission("orders.delete") && form.status !== "completed" && ( )}
{/* Basic info */} Základní údaje { setForm((prev) => ({ ...prev, order_date: val })); setErrors((prev) => ({ ...prev, order_date: "" })); }} /> setForm((prev) => ({ ...prev, delivery_date: val })) } /> setForm((prev) => ({ ...prev, language: val })) } options={[ { value: "cs", label: "Čeština" }, { value: "en", label: "English" }, ]} />