import { useState, useEffect, useMemo, useRef } from "react"; import DOMPurify from "dompurify"; import { useQuery } from "@tanstack/react-query"; import { orderDetailOptions, type OrderItem } from "../lib/queries/orders"; import { useApiMutation } from "../lib/queries/mutations"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import OrderConfirmationModal from "../components/OrderConfirmationModal"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, restoreQuillBlankLines, } from "../utils/formatters"; import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; import { Button, Card, Field, TextField, DataTable, StatusChip, CheckboxField, ConfirmDialog, EmptyState, LoadingState, PageEnter, RichTextView, headerActionsSx, type DataColumn, } from "../ui"; const API_BASE = "/api/admin"; type ItemRow = OrderItem & { _index: number }; const TRANSITION_LABELS: Record = { v_realizaci: "Zahájit realizaci", dokoncena: "Dokončit", }; const BackIcon = ( ); const FileIcon = ( ); export default function OrderDetail() { const { id } = useParams(); const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const orderQuery = useQuery(orderDetailOptions(id)); const order = orderQuery.data; const loading = orderQuery.isPending; const [notes, setNotes] = useState(""); const [saving, setSaving] = useState(false); const [statusChanging, setStatusChanging] = useState(null); const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null; }>({ show: false, status: null }); const [attachmentLoading, setAttachmentLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [confirmationLoading, setConfirmationLoading] = useState(false); const initialNotesRef = useRef(null); const formInitializedRef = useRef(false); const blobTimeoutsRef = useRef[]>([]); // Reset form sync when navigating to a different order useEffect(() => { formInitializedRef.current = false; }, [id]); // Sync order data to local form state on first load useEffect(() => { if (orderQuery.data && !formInitializedRef.current) { const orderData = orderQuery.data!; setNotes(orderData.notes || ""); initialNotesRef.current = orderData.notes || ""; formInitializedRef.current = true; } }, [orderQuery.data]); // Navigate away on fetch error useEffect(() => { if (orderQuery.error) { alert.error("Nepodařilo se načíst objednávku"); navigate("/orders?tab=prijate"); } }, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const blobTimeouts = blobTimeoutsRef.current; return () => { blobTimeouts.forEach(clearTimeout); }; }, []); const isDirty = useMemo(() => { if (!initialNotesRef.current) return false; return notes !== initialNotesRef.current; }, [notes]); useEffect(() => { if (!isDirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [isDirty]); // NET only — received orders are not tax documents (no VAT on them). const totals = useMemo(() => { if (!order?.items) return { total: 0 }; const total = order.items.reduce((sum, item) => { if (Number(item.is_included_in_total)) { return ( sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) ); } return sum; }, 0); return { total }; }, [order]); const statusMutation = useApiMutation<{ status: string }, unknown>({ url: () => `${API_BASE}/orders/${id}`, method: () => "PUT", invalidate: ["orders", "offers", "projects", "invoices"], }); const notesMutation = useApiMutation<{ notes: string }, unknown>({ url: () => `${API_BASE}/orders/${id}`, method: () => "PUT", invalidate: ["orders", "offers", "projects", "invoices"], }); const orderDeleteMutation = useApiMutation< { delete_files: boolean }, unknown >({ url: () => `${API_BASE}/orders/${id}`, method: () => "DELETE", invalidate: ["orders", "offers", "projects", "invoices"], }); if (!hasPermission("orders.view")) return ; 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 (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setStatusChanging(null); } }; const handleSaveNotes = async () => { setSaving(true); try { await notesMutation.mutateAsync({ notes }); alert.success("Poznámky byly uloženy"); initialNotesRef.current = notes; } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setSaving(false); } }; const handleViewAttachment = async () => { const newWindow = window.open("", "_blank"); setAttachmentLoading(true); try { const response = await apiFetch(`${API_BASE}/orders/${id}/attachment`); if (!response.ok) { newWindow?.close(); alert.error("Nepodařilo se stáhnout přílohu"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba připojení"); } finally { setAttachmentLoading(false); } }; const handleGenerateConfirmation = async ( lang: string, customItems?: Array<{ description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; }>, ) => { setConfirmationLoading(true); try { const response = await apiFetch( `${API_BASE}/orders-pdf/${id}/confirmation`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang, items: customItems }), }, ); if (!response.ok) { const result = await response.json().catch(() => ({})); alert.error(result.error || "Nepodařilo se vygenerovat PDF"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `Potvrzeni-${order?.order_number || String(id)}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); blobTimeoutsRef.current.push(timeoutId); } catch { alert.error("Chyba připojení"); } finally { setConfirmationLoading(false); } }; const handleDelete = async () => { setDeleting(true); try { await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles }); alert.success("Objednávka byla smazána"); navigate("/orders?tab=prijate"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; if (loading) { return ; } if (!order) return null; const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({ ...item, _index: index, })); const itemColumns: DataColumn[] = [ { key: "index", header: "#", width: "2.5rem", render: (item) => ( {item._index + 1} ), }, { key: "description", header: "Popis", render: (item) => ( {item.description || "—"} {item.item_description && ( {item.item_description} )} ), }, { key: "quantity", header: "Množství", width: "5.5rem", render: (item) => String(item.quantity), }, { key: "unit", header: "Jednotka", width: "5.5rem", render: (item) => item.unit || "—", }, { key: "unit_price", header: "Jedn. cena", width: "8rem", align: "right", mono: true, render: (item) => formatCurrency(item.unit_price, order.currency), }, { key: "is_included_in_total", header: "V ceně", width: "4rem", render: (item) => (Number(item.is_included_in_total) ? "Ano" : "Ne"), }, { key: "total", header: "Celkem", width: "9rem", align: "right", mono: true, bold: true, render: (item) => formatCurrency( (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), order.currency, ), }, ]; return ( {/* Header */} {/* flexWrap: the title must drop below the Zpět button on phones instead of overflowing the viewport (same as Offer/Invoice detail headers). */} Objednávka {order.order_number} {order.invoice ? ( ) : ( hasPermission("invoices.create") && order.status === "dokoncena" && ( ) )} {hasPermission("orders.view") && ( )} {hasPermission("orders.edit") && (order.valid_transitions?.filter((s) => s !== "stornovana") .length ?? 0) > 0 && order .valid_transitions!.filter((s) => s !== "stornovana") .map((status) => ( ))} {hasPermission("orders.delete") && ( )} {/* Info card */} Informace Nabídka {order.quotation_number} {order.project_code && ( ({order.project_code}) )} Projekt {order.project ? ( {order.project.project_number} — {order.project.name} ) : ( "—" )} Zákazník {order.customer_name || "—"} Číslo obj. zákazníka {order.customer_order_number || "—"} Měna {order.currency} Datum vytvoření {formatDate(order.created_at)} Příloha {order.attachment_name ? ( ) : ( )} {/* Items (read-only) */} Položky columns={itemColumns} rows={itemRows} rowKey={(item) => item.id ?? item._index} empty={} /> {/* Totals (NET only — orders are not tax documents) */} Celkem bez DPH: {formatCurrency(totals.total, order.currency)} {/* Sections (read-only) */} {order.sections?.length > 0 && ( Rozsah projektu {order.scope_title && ( {order.scope_title} )} {order.scope_description && ( {order.scope_description} )} {order.sections.map((section, index) => ( {index + 1}. {(order.language === "CZ" ? section.title_cz || section.title : section.title || section.title_cz) || `Sekce ${index + 1}`} {section.content && ( )} ))} )} {/* Notes (editable) */} Poznámky setNotes(e.target.value)} multiline minRows={4} placeholder="Interní poznámky k objednávce..." fullWidth disabled={!hasPermission("orders.edit")} /> {hasPermission("orders.edit") && ( )} {/* Status change confirmation */} setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav objednávky" message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } cancelText="Zrušit" /> {/* Delete confirmation */} { setDeleteConfirm(false); setDeleteFiles(false); }} onConfirm={handleDelete} title="Smazat objednávku" message={`Opravdu chcete smazat objednávku „${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`} confirmText="Smazat" confirmVariant="danger" loading={deleting} > {order.project?.has_nas_folder && ( )} {/* Order confirmation PDF modal */} {order && ( setShowConfirmationModal(false)} onGenerate={handleGenerateConfirmation} initialItems={order.items.map((it) => ({ description: it.description || "", quantity: Number(it.quantity) || 0, unit: it.unit || "", unit_price: Number(it.unit_price) || 0, is_included_in_total: Number(it.is_included_in_total) !== 0, }))} orderNumber={order.order_number} /> )} ); }