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 { offerCustomersOptions, type Customer } from "../lib/queries/offers"; import { issuedOrderDetailOptions } from "../lib/queries/issued-orders"; import { companySettingsOptions } from "../lib/queries/settings"; import { formatCurrency, todayLocalStr } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, CheckboxField, ConfirmDialog, LoadingState, PageEnter, CustomerPicker, headerActionsSx, } from "../ui"; import { ISSUED_ORDER_STATUS, statusLabel, statusColor, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // 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; quantity: number; unit: string; unit_price: number; vat_rate: number; } interface OrderForm { customer_id: number | null; customer_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", Number(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", Number(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({ customer_id: null, customer_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); 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 customersQuery = useQuery(offerCustomersOptions()); const customers = customersQuery.data ?? []; 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 || customersQuery.isLoading) return; if (!detailQuery.data) return; const d = detailQuery.data; setForm({ customer_id: d.customer_id ?? null, customer_name: d.customer_name ?? "", currency: d.currency || "CZK", apply_vat: d.apply_vat !== false, vat_rate: Number(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: Number(it.quantity) || 1, unit: it.unit || "", unit_price: Number(it.unit_price) || 0, vat_rate: Number(it.vat_rate) || Number(d.vat_rate) || 21, })) : []; if (mapped.length > 0) setItems(mapped); setDataReady(true); }, [ isEdit, dataReady, detailQuery.isLoading, detailQuery.data, customersQuery.isLoading, ]); // ─── Create mode: set the previewed PO number + default issued_by ─── useEffect(() => { if (isEdit || dataReady) return; if (nextNumberQuery.isLoading || customersQuery.isLoading) return; if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data); setDataReady(true); }, [ isEdit, dataReady, nextNumberQuery.isLoading, nextNumberQuery.data, customersQuery.isLoading, ]); // Form is editable only in create mode or while draft/sent. const editable = !isEdit || form.status === "draft" || form.status === "sent"; const canExport = hasPermission("orders.export"); // ─── 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 selectCustomer = (id: number | null) => { const c = id != null ? customers.find((x: Customer) => x.id === id) : null; setForm((prev) => ({ ...prev, customer_id: id, customer_name: c?.name || "", })); setErrors((prev) => ({ ...prev, customer_id: "" })); }; // ─── Submit (create + edit) ─── const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_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); try { const payload: Record = { customer_id: form.customer_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, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, vat_rate: it.vat_rate, position: i, })), }; const data = await saveMutation.mutateAsync(payload); alert.success( isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena", ); 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); } }; // ─── 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"); } 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á"} {poNumber && ( {poNumber} )} {isEdit && ( )} {isEdit && canExport && ( )} {editable && ( )} {isEdit && 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" }, ]} />