import { useState, useEffect, useCallback, useRef, type ChangeEvent, } from "react"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; 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 ConfirmModal from "../components/ConfirmModal"; import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; import AdminDatePicker from "../components/AdminDatePicker"; import RichEditor from "../components/RichEditor"; import useModalLock from "../hooks/useModalLock"; import useDebounce from "../hooks/useDebounce"; import apiFetch from "../utils/api"; import { formatCurrency } from "../utils/formatters"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; interface OfferItem { _key: string; id?: number; description: string; item_description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; } let _itemKeyCounter = 0; const nextItemKey = () => `item-${++_itemKeyCounter}`; interface ScopeSection { title: string; title_cz: string; content: string; } interface OfferForm { quotation_number: string; project_code: string; customer_id: number | null; customer_name: string; created_at: string; valid_until: string; currency: string; language: string; vat_rate: number; apply_vat: boolean; exchange_rate: string; scope_title: string; scope_description: string; } interface Customer { id: number; name: string; city?: string; } interface OrderInfo { id: number; order_number: string; } const emptyForm: OfferForm = { quotation_number: "", project_code: "", customer_id: null, customer_name: "", created_at: new Date().toISOString().split("T")[0], valid_until: "", currency: "CZK", language: "EN", vat_rate: 21, apply_vat: false, exchange_rate: "", scope_title: "", scope_description: "", }; const emptyScopeSection = (): ScopeSection => ({ title: "", title_cz: "", content: "", }); const emptyItem = (): OfferItem => ({ _key: nextItemKey(), description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, is_included_in_total: true, }); function SortableItemRow({ item, index, currency, readOnly, canDelete, onUpdate, onRemove, }: { item: OfferItem; index: number; currency: string; readOnly: boolean; canDelete: boolean; onUpdate: (field: string, value: unknown) => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item._key, disabled: readOnly }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--bg-secondary)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); return ( {!readOnly && ( )} {index + 1}
onUpdate("description", e.target.value)} className="admin-form-input" placeholder="Název položky" readOnly={readOnly} style={{ fontWeight: 500 }} /> onUpdate("item_description", e.target.value)} className="admin-form-input" placeholder="Podrobný popis (volitelný)" readOnly={readOnly} style={{ fontSize: "0.8rem", opacity: 0.8 }} />
onUpdate("quantity", parseFloat(e.target.value) || 0) } className="admin-form-input" step="1" readOnly={readOnly} /> onUpdate("unit", e.target.value)} className="admin-form-input" readOnly={readOnly} /> onUpdate("unit_price", parseFloat(e.target.value) || 0) } className="admin-form-input" step="0.01" readOnly={readOnly} /> onUpdate("is_included_in_total", e.target.checked)} disabled={readOnly} /> {formatCurrency(lineTotal, currency)} {!readOnly && ( )} ); } export default function OfferDetail() { const { id } = useParams(); const isEdit = Boolean(id); const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 }, }), useSensor(KeyboardSensor), ); const [loading, setLoading] = useState(isEdit); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); const [form, setForm] = useState(emptyForm); const [items, setItems] = useState([emptyItem()]); const [sections, setSections] = useState([]); const [scopeTemplates, setScopeTemplates] = useState< Array<{ id: number; name: string; description?: string; scope_template_sections?: Array<{ title?: string; title_cz?: string; content?: string; }>; }> >([]); const [customers, setCustomers] = useState([]); const [customerSearch, setCustomerSearch] = useState(""); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const [orderInfo, setOrderInfo] = useState(null); const [offerStatus, setOfferStatus] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const [creatingOrder, setCreatingOrder] = useState(false); const [showOrderModal, setShowOrderModal] = useState(false); const [invalidateConfirm, setInvalidateConfirm] = useState(false); const [invalidatingOffer, setInvalidatingOffer] = useState(false); const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [orderAttachment, setOrderAttachment] = useState(null); const [pdfLoading, setPdfLoading] = useState(false); const [companySettings, setCompanySettings] = useState<{ default_currency: string; default_vat_rate: number; available_currencies: string[]; available_vat_rates: number[]; } | null>(null); const [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string; } | null>(null); const heartbeatRef = useRef | null>(null); useModalLock(showOrderModal); useEffect(() => { apiFetch(`${API_BASE}/company-settings`) .then((r) => r.json()) .then((d) => { if (d.success) setCompanySettings(d.data); }) .catch(() => {}); }, []); useEffect(() => { if (companySettings && !isEdit) { setForm((prev) => ({ ...prev, currency: prev.currency === "EUR" ? companySettings.default_currency || "CZK" : prev.currency, vat_rate: prev.vat_rate === 21 ? (companySettings.default_vat_rate ?? 21) : prev.vat_rate, })); } }, [companySettings, isEdit]); const isInvalidated = offerStatus === "invalidated"; const isLockedByOther = !!lockedBy; const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString()); const fetchDetail = useCallback(async () => { if (!id) return; try { const response = await apiFetch(`${API_BASE}/offers/${id}`); if (response.status === 401) return; const result = await response.json(); if (result.success) { const d = result.data; setForm({ quotation_number: d.quotation_number || "", project_code: d.project_code || "", customer_id: d.customer_id || null, customer_name: d.customer_name || "", created_at: d.created_at ? String(d.created_at).substring(0, 10) : "", valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "", currency: d.currency || companySettings?.default_currency || "CZK", language: d.language || "EN", vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21, apply_vat: !!d.apply_vat, exchange_rate: d.exchange_rate || "", scope_title: d.scope_title || "", scope_description: d.scope_description || "", }); setItems( d.items?.length ? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) : [emptyItem()], ); setSections( d.sections?.length ? d.sections.map((s: any) => ({ title: s.title || "", title_cz: s.title_cz || "", content: s.content || "", })) : [], ); setOfferStatus(d.status || ""); setOrderInfo(d.order || null); setLockedBy(d.locked_by || null); // Try to acquire lock if not locked by someone else and not invalidated if ( !d.locked_by && d.status !== "invalidated" && hasPermission("offers.edit") ) { apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch( () => {}, ); } } else { alert.error(result.error || "Nepodařilo se načíst nabídku"); navigate("/offers"); } } catch { alert.error("Chyba připojení"); navigate("/offers"); } finally { setLoading(false); } }, [id, alert, navigate, hasPermission, companySettings]); // Heartbeat to keep lock alive + cleanup on unmount useEffect(() => { if (!isEdit || !id || isLockedByOther || isInvalidated) return; heartbeatRef.current = setInterval(() => { apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch( () => {}, ); }, 10 * 1000); // every 10 seconds return () => { if (heartbeatRef.current) clearInterval(heartbeatRef.current); // Release lock on unmount apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch( () => {}, ); }; }, [isEdit, id, isLockedByOther, isInvalidated]); useEffect(() => { if (isEdit) fetchDetail(); }, [isEdit, fetchDetail]); useEffect(() => { const loadCustomers = async () => { try { const res = await apiFetch(`${API_BASE}/customers`); if (res.status === 401) return; const data = await res.json(); if (data.success) setCustomers( Array.isArray(data.data) ? data.data : data.data?.customers || [], ); } catch { /* silent */ } }; const loadScopeTemplates = async () => { try { const res = await apiFetch(`${API_BASE}/offers-templates`); if (res.status === 401) return; const data = await res.json(); if (data.success && Array.isArray(data.data)) { setScopeTemplates(data.data); } } catch { /* silent */ } }; loadCustomers(); loadScopeTemplates(); }, []); // Close dropdown on outside click useEffect(() => { const handleClickOutside = () => setShowCustomerDropdown(false); if (showCustomerDropdown) { document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); } }, [showCustomerDropdown]); useEffect(() => { if (isEdit) return; const fetchNextNumber = async () => { try { const res = await apiFetch(`${API_BASE}/offers/next-number`); if (res.status === 401) return; const data = await res.json(); if (data.success) { setForm((prev) => ({ ...prev, quotation_number: data.data?.next_number || data.data?.number || "", })); } } catch { /* silent */ } }; fetchNextNumber(); }, [isEdit]); // Restore draft from localStorage on mount (create mode only) const draftRestoredRef = useRef(false); useEffect(() => { if (isEdit || draftRestoredRef.current) return; draftRestoredRef.current = true; try { const raw = localStorage.getItem(DRAFT_KEY); if (!raw) return; const draft = JSON.parse(raw); if (draft && draft.form) { setForm((prev) => ({ ...prev, project_code: draft.form.project_code || prev.project_code, customer_name: draft.form.customer_name || prev.customer_name, created_at: draft.form.created_at || prev.created_at, valid_until: draft.form.valid_until || prev.valid_until, currency: draft.form.currency || prev.currency, })); if (draft.form.customer_id) { setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id })); } } if (draft && Array.isArray(draft.items) && draft.items.length > 0) { setItems(draft.items); } if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) { setSections(draft.sections); } } catch { /* ignore corrupt data */ } }, [isEdit]); // Auto-save draft to localStorage (create mode only) const draftPayload = JSON.stringify({ form, items, sections }); const debouncedDraft = useDebounce(draftPayload, 1500); useEffect(() => { if (isEdit) return; try { const draft = { form: { project_code: form.project_code, customer_id: form.customer_id, customer_name: form.customer_name, created_at: form.created_at, valid_until: form.valid_until, currency: form.currency, }, items, sections, savedAt: new Date().toISOString(), }; localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch { /* localStorage full or unavailable */ } }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps const updateForm = (field: keyof OfferForm, value: unknown) => { setForm((prev) => ({ ...prev, [field]: value })); setErrors((prev) => ({ ...prev, [field]: undefined })); }; const selectCustomer = (c: Customer) => { setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name })); setErrors((prev) => ({ ...prev, customer_id: undefined })); setCustomerSearch(""); setShowCustomerDropdown(false); }; const clearCustomer = () => { setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" })); }; const updateItem = ( index: number, field: keyof OfferItem, value: unknown, ) => { setItems((prev) => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), ); }; const addItem = () => setItems((prev) => [...prev, emptyItem()]); const removeItem = (index: number) => { setItems((prev) => prev.filter((_, i) => i !== index)); }; const subtotal = items.reduce((sum, item) => { if (item.is_included_in_total) { return ( sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) ); } return sum; }, 0); const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0; const total = subtotal + vatAmount; const filteredCustomers = customerSearch ? customers.filter((c) => c.name.toLowerCase().includes(customerSearch.toLowerCase()), ) : customers; const handleSave = async () => { const newErrors: Record = {}; if (!form.created_at) newErrors.created_at = "Datum je povinné"; if (!form.valid_until) newErrors.valid_until = "Platnost je povinná"; if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku"; setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); try { const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const response = await apiFetch(url, { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, items: items.map((item, i) => ({ ...item, position: i })), sections: sections.map((s, i) => ({ ...s, position: i })), }), }); const result = await response.json(); if (result.success) { const offerId = isEdit ? id : result.data?.id; if (offerId) { await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch( () => {}, ); } alert.success( result.message || (isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"), ); if (!isEdit) { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } } if (!isEdit && result.data?.id) { navigate(`/offers/${result.data.id}`); } } else { alert.error(result.error || "Nepodařilo se uložit nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; const handleCreateOrder = async () => { if (!customerOrderNumber.trim()) { alert.error("Číslo objednávky zákazníka je povinné"); return; } setCreatingOrder(true); try { let fetchOptions: RequestInit; if (orderAttachment) { // With attachment: send as multipart/form-data const formData = new FormData(); formData.append("quotationId", String(id)); formData.append("customerOrderNumber", customerOrderNumber.trim()); formData.append("attachment", orderAttachment); fetchOptions = { method: "POST", body: formData }; } else { // Without attachment: send as JSON (avoids multipart content-type issues) fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quotationId: id, customerOrderNumber: customerOrderNumber.trim(), }), }; } const response = await apiFetch(`${API_BASE}/orders`, fetchOptions); const result = await response.json(); if (result.success) { setShowOrderModal(false); alert.success(result.message || "Objednávka byla vytvořena"); navigate(`/orders/${result.data.order_id}`); } else { alert.error(result.error || "Nepodařilo se vytvořit objednávku"); } } catch { alert.error("Chyba připojení"); } finally { setCreatingOrder(false); } }; const handleInvalidateOffer = async () => { setInvalidatingOffer(true); try { const response = await apiFetch(`${API_BASE}/offers/${id}/invalidate`, { method: "POST", }); const result = await response.json(); if (result.success) { setInvalidateConfirm(false); setOfferStatus("invalidated"); alert.success(result.message || "Nabídka byla zneplatněna"); } else { alert.error(result.error || "Nepodařilo se zneplatnit nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setInvalidatingOffer(false); } }; const handleDelete = async () => { setDeleting(true); try { const response = await apiFetch(`${API_BASE}/offers/${id}`, { method: "DELETE", }); const result = await response.json(); if (result.success) { alert.success(result.message || "Nabídka byla smazána"); navigate("/offers"); } else { alert.error(result.error || "Nepodařilo se smazat nabídku"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; const handlePdf = async () => { if (!isEdit || pdfLoading) return; setPdfLoading(true); try { const response = await apiFetch(`${API_BASE}/offers/${id}/file`); if (response.status === 401) return; if (!response.ok) { alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); window.open(url, "_blank"); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch { alert.error("Chyba při generování PDF"); } finally { setPdfLoading(false); } }; const getRequiredPerm = () => { if (!isEdit) return "offers.create"; return isInvalidated ? "offers.view" : "offers.edit"; }; const requiredPerm = getRequiredPerm(); if (!hasPermission(requiredPerm)) return ; if (loading) { return (
{[0, 1, 2, 3].map((i) => (
))}
); } return (
{/* Header */}

{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} {isInvalidated && ( Zneplatněna )}

{isEdit && hasPermission("offers.export") && ( )} {isEdit && !isInvalidated && hasPermission("orders.create") && !orderInfo && ( )} {isEdit && orderInfo && ( Objednávka {orderInfo.order_number} )} {isExpiredNotInvalidated && hasPermission("offers.edit") && ( )} {!isInvalidated && !isLockedByOther && ( )} {isEdit && hasPermission("offers.delete") && ( )}
{/* Lock banner */} {isLockedByOther && (
Nabídku právě upravuje {lockedBy!.full_name}. Můžete ji pouze prohlížet.
)} {/* Quotation Form */}

Základní údaje

setForm((prev) => ({ ...prev, quotation_number: e.target.value, })) } className="admin-form-input" /> updateForm("project_code", e.target.value)} className="admin-form-input" placeholder="Volitelný kód projektu" readOnly={isInvalidated || isLockedByOther} /> {form.customer_id ? (
{form.customer_name} {!isInvalidated && !isLockedByOther && ( )}
) : (
e.stopPropagation()} > { setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} className="admin-form-input" placeholder="Hledat zákazníka..." readOnly={isInvalidated || isLockedByOther} /> {showCustomerDropdown && !isInvalidated && (
{filteredCustomers.length === 0 ? (
Žádní zákazníci
) : ( filteredCustomers.slice(0, 20).map((c) => (
selectCustomer(c)} >
{c.name}
{c.city &&
{c.city}
}
)) )}
)}
)}
{isInvalidated || isLockedByOther ? ( ) : ( { updateForm("created_at", val); setErrors((prev) => ({ ...prev, created_at: undefined })); }} /> )} {isInvalidated || isLockedByOther ? ( ) : ( { updateForm("valid_until", val); setErrors((prev) => ({ ...prev, valid_until: undefined })); }} /> )}
updateForm("exchange_rate", e.target.value)} className="admin-form-input" placeholder="Volitelný" step="0.0001" readOnly={isInvalidated || isLockedByOther} />
{/* Items Section with drag-and-drop */}

Položky

{!isInvalidated && !isLockedByOther && ( )}
{errors.items && (

{errors.items}

)}
{ 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); }); }} > i._key)} strategy={verticalListSortingStrategy} > {!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && ( {items.map((item, index) => ( 1} onUpdate={(field, value) => updateItem(index, field, value) } onRemove={() => removeItem(index)} /> ))}
)} # Popis Množství Jednotka Cena/ks V ceně Celkem )}
{/* Totals */}
Mezisoučet: {formatCurrency(subtotal, form.currency)}
{form.apply_vat && (
DPH ({form.vat_rate}%): {formatCurrency(vatAmount, form.currency)}
)}
Celkem: {formatCurrency(total, form.currency)}
{/* Scope/Range Section */}

Rozsah projektu

{!isInvalidated && !isLockedByOther && (
{scopeTemplates.length > 0 && ( )}
)}
{sections.length === 0 ? (

Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.

) : (
{sections.map((section, idx) => (
Sekce {idx + 1} {(form.language === "CZ" ? section.title_cz : section.title) && ( —{" "} {form.language === "CZ" ? section.title_cz || section.title : section.title} )} {!isInvalidated && !isLockedByOther && (
{idx > 0 && ( )} {idx < sections.length - 1 && ( )}
)}
ENNázev sekce } > setSections((prev) => prev.map((s, i) => i === idx ? { ...s, title: e.target.value } : s, ), ) } className="admin-form-input" placeholder="Název sekce (anglicky)" readOnly={isInvalidated || isLockedByOther} /> CZ Název sekce } > setSections((prev) => prev.map((s, i) => i === idx ? { ...s, title_cz: e.target.value } : s, ), ) } className="admin-form-input" placeholder="Název sekce (česky)" readOnly={isInvalidated || isLockedByOther} />
setSections((prev) => prev.map((s, i) => i === idx ? { ...s, content: val } : s, ), ) } placeholder="Obsah sekce..." minHeight="120px" readOnly={isInvalidated || isLockedByOther} />
))}
)}
{/* Order modal */} {showOrderModal && (
!creatingOrder && setShowOrderModal(false)} />

Vytvořit objednávku

) => setCustomerOrderNumber(e.target.value) } onKeyDown={(e) => e.key === "Enter" && !creatingOrder && handleCreateOrder() } className="admin-form-input" placeholder="Např. PO-2026-001" autoFocus /> {orderAttachment ? (
{orderAttachment.name}{" "} ({(orderAttachment.size / 1024).toFixed(0)} KB)
) : ( )} Max 10 MB
)} setInvalidateConfirm(false)} onConfirm={handleInvalidateOffer} title="Zneplatnit nabídku" message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`} confirmText="Zneplatnit" cancelText="Zrušit" type="danger" loading={invalidatingOffer} /> setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat nabídku" message={`Opravdu chcete smazat nabídku "${form.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" type="danger" loading={deleting} />
); }