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: 'EUR', 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 }>>([]) 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 [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string } | null>(null) const heartbeatRef = useRef | null>(null) useModalLock(showOrderModal) 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()) // Load data 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 || 'EUR', language: d.language || 'EN', vat_rate: d.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]) // 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(() => {}) }, 2 * 60 * 1000) // every 2 minutes 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]) // Fetch next quotation number for new offers 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)) } // Totals 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) { 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-pdf/${id}`) if (response.status === 401) return if (!response.ok) { alert.error('Nepodařilo se vygenerovat PDF') return } const html = await response.text() const w = window.open('', '_blank') if (w) { w.document.open() w.document.write(html) w.document.close() w.onload = () => w.print() } else { alert.error('Prohlížeč zablokoval vyskakovací okno') } } 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('vat_rate', parseFloat(e.target.value) || 0)} className="admin-form-input flex-1" step="0.1" readOnly={isInvalidated || isLockedByOther} />
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} /> CZNá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} />
{(isInvalidated || isLockedByOther) ? (
Prázdný obsah' }} /> ) : ( setSections(prev => prev.map((s, i) => i === idx ? { ...s, content: val } : s))} placeholder="Obsah sekce..." minHeight="120px" /> )}
))}
)}
{/* 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} />
) }