import { useState, useEffect, useMemo, useRef, type ReactNode } from "react"; import DOMPurify from "dompurify"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { orderDetailOptions, type OrderData, type OrderItem, type OrderSection, type OrderInvoice, type OrderProject, } from "../lib/queries/orders"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link } from "react-router-dom"; import { motion } from "framer-motion"; import ConfirmModal from "../components/ConfirmModal"; import OrderConfirmationModal from "../components/OrderConfirmationModal"; import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; import { Skeleton } from "boneyard-js/react"; import OrderDetailFixture from "../fixtures/OrderDetailFixture"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate } from "../utils/formatters"; const API_BASE = "/api/admin"; const STATUS_LABELS: Record = { prijata: "Přijatá", v_realizaci: "V realizaci", dokoncena: "Dokončená", stornovana: "Stornována", }; const STATUS_CLASSES: Record = { prijata: "admin-badge-order-prijata", v_realizaci: "admin-badge-order-realizace", dokoncena: "admin-badge-order-dokoncena", stornovana: "admin-badge-order-stornovana", }; const TRANSITION_LABELS: Record = { v_realizaci: "Zahájit realizaci", dokoncena: "Dokončit", }; const TRANSITION_CLASSES: Record = { v_realizaci: "admin-btn admin-btn-primary", dokoncena: "admin-btn admin-btn-primary", }; export default function OrderDetail() { const { id } = useParams(); const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient(); 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"); } }, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { return () => { blobTimeoutsRef.current.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]); const totals = useMemo(() => { if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }; const subtotal = 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); const vatAmount = Number(order.apply_vat) ? subtotal * ((Number(order.vat_rate) || 0) / 100) : 0; return { subtotal, vatAmount, total: subtotal + vatAmount }; }, [order]); if (!hasPermission("orders.view")) return ; const handleStatusChange = async () => { if (!statusConfirm.status) return; setStatusChanging(statusConfirm.status); setStatusConfirm({ show: false, status: null }); try { const response = await apiFetch(`${API_BASE}/orders/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: statusConfirm.status }), }); const result = await response.json(); if (result.success) { alert.success(result.message || "Stav byl změněn"); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); } else { alert.error(result.error || "Nepodařilo se změnit stav"); } } catch { alert.error("Chyba připojení"); } finally { setStatusChanging(null); } }; const handleSaveNotes = async () => { setSaving(true); try { const response = await apiFetch(`${API_BASE}/orders/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes: notes }), }); const result = await response.json(); if (result.success) { alert.success("Poznámky byly uloženy"); initialNotesRef.current = notes; queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); } else { alert.error(result.error || "Nepodařilo se uložit poznámky"); } } catch { alert.error("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, applyVat: boolean, customItems?: Array<{ description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; vat_rate: number; }>, ) => { setConfirmationLoading(true); try { const response = await apiFetch( `${API_BASE}/orders-pdf/${id}/confirmation`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang, applyVat, 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 { const response = await apiFetch(`${API_BASE}/orders/${id}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete_files: deleteFiles }), }); const result = await response.json(); if (result.success) { alert.success(result.message || "Objednávka byla smazána"); await queryClient.invalidateQueries({ queryKey: ["orders"] }); await queryClient.invalidateQueries({ queryKey: ["offers"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["invoices"] }); navigate("/orders"); } else { alert.error(result.error || "Nepodařilo se smazat objednávku"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; if (!order) return null; return ( } >
{/* Header */}

Objednávka {order.order_number} {STATUS_LABELS[order.status] || order.status}

{order.invoice ? ( Faktura {order.invoice.invoice_number} ) : ( hasPermission("invoices.create") && order.status === "dokoncena" && ( Vytvořit fakturu ) )} {hasPermission("orders.export") && ( )} {hasPermission("orders.edit") && order.valid_transitions?.filter((s) => s !== "stornovana") .length! > 0 && order .valid_transitions!.filter((s) => s !== "stornovana") .map((status) => ( ))} {hasPermission("orders.delete") && ( )}
{/* Info card */}

Informace

{order.quotation_number} {order.project_code && ( ({order.project_code}) )}
{order.project ? ( {order.project.project_number} — {order.project.name} ) : ( "—" )}
{order.customer_name || "—"}
{order.customer_order_number || "—"}
{order.currency}
{formatDate(order.created_at)}
{order.attachment_name ? ( ) : ( "—" )}
{/* Items (read-only) */}

Položky

{order.items?.length > 0 ? (
{order.items.map((item, index) => { const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); return ( ); })}
# Popis Množství Jednotka Jedn. cena V ceně Celkem
{index + 1}
{item.description || "—"}
{item.item_description && (
{item.item_description}
)}
{item.quantity} {item.unit || "—"} {formatCurrency(item.unit_price, order.currency)} {Number(item.is_included_in_total) ? "Ano" : "Ne"} {formatCurrency(lineTotal, order.currency)}
) : (

Žádné položky.

)} {/* Totals */}
Mezisoučet: {formatCurrency(totals.subtotal, order.currency)}
{Number(order.apply_vat) > 0 && (
DPH ({order.vat_rate}%): {formatCurrency(totals.vatAmount, order.currency)}
)}
Celkem k úhradě: {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