import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { useNavigate, useSearchParams, useParams, Link } from 'react-router-dom' import { useAlert } from '../context/AlertContext' import { useAuth } from '../context/AuthContext' import Forbidden from '../components/Forbidden' import FormField from '../components/FormField' import AdminDatePicker from '../components/AdminDatePicker' import ConfirmModal from '../components/ConfirmModal' 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 apiFetch from '../utils/api' import { formatCurrency, formatDate } from '../utils/formatters' const API_BASE = '/api/admin' const STATUS_LABELS: Record = { issued: 'Vystavena', paid: 'Zaplacena', overdue: 'Po splatnosti' } const STATUS_CLASSES: Record = { issued: 'admin-badge-invoice-issued', paid: 'admin-badge-invoice-paid', overdue: 'admin-badge-invoice-overdue' } const TRANSITION_LABELS: Record = { paid: 'Zaplaceno' } const TRANSITION_CLASSES: Record = { paid: 'admin-btn admin-btn-primary' } const VAT_OPTIONS = [ { value: 21, label: '21%' }, { value: 12, label: '12%' }, { value: 0, label: '0%' } ] interface InvoiceItem { id?: number _key: string description: string quantity: number unit: string unit_price: number vat_rate: number } interface Customer { id: number name: string company_id?: string city?: string } interface BankAccount { id: number account_name: string account_number?: string bank_name?: string bic?: string iban?: string is_default?: boolean } interface InvoiceForm { customer_id: number | null customer_name: string order_id: number | null issue_date: string due_date: string tax_date: string currency: string apply_vat: number vat_rate: number payment_method: string constant_symbol: string issued_by: string billing_text: string notes: string bank_account_id: number | string bank_name: string bank_swift: string bank_iban: string bank_account: string } interface InvoiceCustomer { company_id?: string vat_id?: string } interface Invoice { id: number invoice_number: string customer_name: string | null customer?: InvoiceCustomer order_id?: number order_number?: string currency: string status: string issue_date: string due_date: string tax_date: string payment_method: string issued_by: string | null paid_date?: string notes: string apply_vat: number | string items: Omit[] valid_transitions?: string[] } // Sortable row for create mode function SortableInvoiceRow({ item, index, currency, apply_vat, onUpdate, onRemove, canDelete }: { item: InvoiceItem; index: number; currency: string; apply_vat: boolean; onUpdate: (index: number, field: keyof InvoiceItem, value: string | number) => void; onRemove: (index: number) => void; canDelete: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key }) 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 ( {index + 1} onUpdate(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> onUpdate(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> onUpdate(index, 'unit', e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> onUpdate(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> {apply_vat ? ( ) : null} {formatCurrency(lineTotal, currency)} {canDelete && ( )} ) } // Sortable row for edit mode (existing invoice items) function SortableInvoiceEditRow({ item, index, apply_vat, onUpdate, onRemove, canDelete }: { item: InvoiceItem; index: number; apply_vat: boolean; onUpdate: (index: number, field: string, value: string | number) => void; onRemove: (index: number) => void; canDelete: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key }) 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, } return ( {index + 1} onUpdate(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> onUpdate(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> onUpdate(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> onUpdate(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> {apply_vat ? ( ) : ( 0% )}
{canDelete && ( )}
) } export default function InvoiceDetail() { const { id } = useParams<{ id: string }>() const isEdit = Boolean(id) const keyCounterRef = useRef(0) const emptyItem = useCallback((): InvoiceItem => ({ _key: `inv-${++keyCounterRef.current}`, 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 navigate = useNavigate() const [searchParams] = useSearchParams() const alert = useAlert() const { hasPermission, user } = useAuth() // ─── Create mode state ─── const rawOrderId = searchParams.get('fromOrder') const fromOrderId = !isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null const [form, setForm] = useState({ customer_id: null, customer_name: '', order_id: fromOrderId ? Number(fromOrderId) : null, issue_date: new Date().toISOString().split('T')[0], due_date: new Date(Date.now() + 14 * 86400000).toISOString().split('T')[0], tax_date: new Date().toISOString().split('T')[0], currency: 'CZK', apply_vat: 1, vat_rate: 21, payment_method: 'Příkazem', constant_symbol: '0308', issued_by: user?.fullName || '', billing_text: '', notes: '', bank_account_id: '', bank_name: '', bank_swift: '', bank_iban: '', bank_account: '' }) const [bankAccounts, setBankAccounts] = useState([]) const [dueDays, setDueDays] = useState(14) const [items, setItems] = useState([emptyItem()]) const [errors, setErrors] = useState>({}) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) const [invoiceNumber, setInvoiceNumber] = useState('') // Customer selector (create mode) const [customers, setCustomers] = useState([]) const [customerSearch, setCustomerSearch] = useState('') const [showCustomerDropdown, setShowCustomerDropdown] = useState(false) // Draft const DRAFT_KEY = 'boha_invoice_draft' const clearDraft = useCallback(() => { try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ } }, []) // ─── Edit mode state ─── const [invoice, setInvoice] = useState(null) const [notes, setNotes] = 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 [langModal, setLangModal] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleting, setDeleting] = useState(false) // Edit items (edit mode) const [editingItems, setEditingItems] = useState(false) const [editItems, setEditItems] = useState([]) const editKeyCounter = useRef(0) // ─── Data loading ─── // Create mode: load next number, customers, bank accounts, order data useEffect(() => { if (isEdit) return const load = async () => { try { const promises = [ apiFetch(`${API_BASE}/invoices/next-number`), apiFetch(`${API_BASE}/customers`), apiFetch(`${API_BASE}/bank-accounts`) ] if (fromOrderId) { promises.push(apiFetch(`${API_BASE}/invoices/order-data/${fromOrderId}`)) } const results = await Promise.all(promises) const numRes = results[0] if (numRes.ok) { const numData = await numRes.json() if (numData.success) setInvoiceNumber(numData.data?.next_number || numData.data?.number || '') } const custRes = results[1] if (custRes.ok) { const custData = await custRes.json() if (custData.success) setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.customers || []) } const bankRes = results[2] if (bankRes.ok) { const bankData = await bankRes.json() if (bankData.success && Array.isArray(bankData.data)) { setBankAccounts(bankData.data) const defaultAcc = bankData.data.find((a: BankAccount) => a.is_default) if (defaultAcc) { setForm(prev => ({ ...prev, bank_account_id: defaultAcc.id, bank_name: defaultAcc.bank_name || '', bank_swift: defaultAcc.bic || '', bank_iban: defaultAcc.iban || '', bank_account: defaultAcc.account_number || '' })) } } } // Pre-fill from order if (fromOrderId && results[3]?.ok) { const orderData = await results[3].json() if (orderData.success) { const order = orderData.data const vatRate = Number(order.vat_rate) || 21 setForm(prev => ({ ...prev, customer_id: order.customer_id, customer_name: order.customer_name || '', order_id: order.id, currency: order.currency || 'CZK', apply_vat: Number(order.apply_vat) || 0, vat_rate: vatRate })) if (order.items?.length > 0) { setItems(order.items.map((item: Record) => ({ _key: `inv-${++keyCounterRef.current}`, description: (item.description as string) || '', quantity: Number(item.quantity) || 1, unit: (item.unit as string) || '', unit_price: Number(item.unit_price) || 0, vat_rate: vatRate }))) } } } } catch { alert.error('Chyba při načítání dat') } finally { setLoading(false) } } load() }, [isEdit, fromOrderId, alert]) // Edit mode: load existing invoice const fetchDetail = useCallback(async () => { if (!id) return try { const response = await apiFetch(`${API_BASE}/invoices/${id}`) if (response.status === 401) return const result = await response.json() if (result.success) { setInvoice(result.data) setNotes(result.data.notes || '') } else { alert.error(result.error || 'Nepodařilo se načíst fakturu') navigate('/invoices') } } catch { alert.error('Chyba připojení') navigate('/invoices') } finally { setLoading(false) } }, [id, alert, navigate]) useEffect(() => { if (isEdit) fetchDetail() }, [isEdit, fetchDetail]) // ─── Create mode: due date calculation ─── useEffect(() => { if (isEdit) return if (!form.issue_date) return const d = new Date(form.issue_date) d.setDate(d.getDate() + dueDays) setForm(prev => ({ ...prev, due_date: d.toISOString().split('T')[0] })) }, [isEdit, form.issue_date, dueDays]) // ─── Create mode: customer filtering ─── const filteredCustomers = useMemo(() => { if (!customerSearch) return customers const q = customerSearch.toLowerCase() return customers.filter(c => (c.name || '').toLowerCase().includes(q) || (c.company_id || '').includes(customerSearch) || (c.city || '').toLowerCase().includes(q) ) }, [customers, customerSearch]) useEffect(() => { const handleClickOutside = () => setShowCustomerDropdown(false) if (showCustomerDropdown) { document.addEventListener('click', handleClickOutside) return () => document.removeEventListener('click', handleClickOutside) } }, [showCustomerDropdown]) const selectBankAccount = (accountId: string) => { const acc = bankAccounts.find(a => a.id === Number(accountId)) if (acc) { setForm(prev => ({ ...prev, bank_account_id: acc.id, bank_name: acc.bank_name || '', bank_swift: acc.bic || '', bank_iban: acc.iban || '', bank_account: acc.account_number || '' })) } else { setForm(prev => ({ ...prev, bank_account_id: '', bank_name: '', bank_swift: '', bank_iban: '', bank_account: '' })) } } const selectCustomer = (customer: Customer) => { setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name })) setErrors(prev => ({ ...prev, customer_id: '' })) setCustomerSearch('') setShowCustomerDropdown(false) } // ─── Create mode: items management ─── const updateItem = (index: number, field: keyof InvoiceItem, value: string | number) => { setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item)) } const addItem = () => setItems(prev => [...prev, emptyItem()]) const removeItem = (index: number) => { if (items.length <= 1) return setItems(prev => prev.filter((_, i) => i !== index)) } const handleCreateDragEnd = (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) }) } // ─── Create mode: totals ─── const createTotals = useMemo(() => { let subtotal = 0 const vatByRate: Record = {} items.forEach(item => { const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) subtotal += lineTotal if (form.apply_vat) { const rate = Number(item.vat_rate) || 0 if (!vatByRate[rate]) vatByRate[rate] = 0 vatByRate[rate] += lineTotal * rate / 100 } }) const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0) return { subtotal, vatByRate, totalVat, total: subtotal + totalVat } }, [items, form.apply_vat]) // ─── Create mode: submit ─── const handleCreateSubmit = async (e?: React.FormEvent) => { e?.preventDefault() const newErrors: Record = {} if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka' if (!form.issue_date) newErrors.issue_date = 'Zadejte datum' if (!form.tax_date) newErrors.tax_date = 'Zadejte datum' if (!form.bank_account_id) newErrors.bank_account_id = 'Vyberte bankovní účet' 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 response = await apiFetch(`${API_BASE}/invoices`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...form, invoice_number: invoiceNumber, items: items.filter(i => i.description.trim()).map((item, i) => ({ ...item, position: i })) }) }) const result = await response.json() if (result.success) { clearDraft() alert.success(result.message || 'Faktura byla vytvořena') navigate(`/invoices/${result.data.invoice_id}`) } else { alert.error(result.error || 'Nepodařilo se vytvořit fakturu') } } catch { alert.error('Chyba připojení') } finally { setSaving(false) } } // ─── Edit mode: totals ─── const editTotals = useMemo(() => { if (!invoice?.items) return { subtotal: 0, vatByRate: {} as Record, totalVat: 0, total: 0 } let subtotal = 0 const vatByRate: Record = {} invoice.items.forEach(item => { const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) subtotal += lineTotal if (Number(invoice.apply_vat)) { const rate = Number(item.vat_rate) || 0 if (!vatByRate[rate]) vatByRate[rate] = 0 vatByRate[rate] += lineTotal * rate / 100 } }) const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0) return { subtotal, vatByRate, totalVat, total: subtotal + totalVat } }, [invoice]) // ─── Edit mode: status change ─── const handleStatusChange = async () => { if (!statusConfirm.status) return setStatusChanging(statusConfirm.status) setStatusConfirm({ show: false, status: null }) try { const response = await apiFetch(`${API_BASE}/invoices/${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') fetchDetail() } else { alert.error(result.error || 'Nepodařilo se změnit stav') } } catch { alert.error('Chyba připojení') } finally { setStatusChanging(null) } } // ─── Edit mode: save notes ─── const handleSaveNotes = async () => { setSaving(true) try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes }) }) const result = await response.json() if (result.success) { alert.success('Poznámky byly uloženy') } else { alert.error(result.error || 'Nepodařilo se uložit poznámky') } } catch { alert.error('Chyba připojení') } finally { setSaving(false) } } // ─── Edit mode: PDF export ─── const handleViewPdf = async (lang = 'cs') => { setLangModal(false) const newWindow = window.open('', '_blank') setPdfLoading(true) try { const response = await apiFetch(`${API_BASE}/invoices-pdf/${id}?lang=${encodeURIComponent(lang)}`) if (!response.ok) { newWindow?.close() alert.error('Nepodařilo se vygenerovat PDF') return } const html = await response.text() if (newWindow) { newWindow.document.open() newWindow.document.write(html) newWindow.document.close() newWindow.onload = () => newWindow.print() } } catch { newWindow?.close() alert.error('Chyba připojení') } finally { setPdfLoading(false) } } // ─── Edit mode: edit items ─── const startEditItems = () => { if (!invoice) return setEditItems(invoice.items.map(item => ({ _key: `ei-${++editKeyCounter.current}`, description: item.description || '', quantity: Number(item.quantity) || 1, unit: item.unit || '', unit_price: Number(item.unit_price) || 0, vat_rate: Number(item.vat_rate) || 21 }))) setEditingItems(true) } const updateEditItem = (index: number, field: string, value: string | number) => { setEditItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item)) } const addEditItem = () => { setEditItems(prev => [...prev, { _key: `ei-${++editKeyCounter.current}`, description: '', quantity: 1, unit: 'ks', unit_price: 0, vat_rate: 21 }]) } const removeEditItem = (index: number) => { if (editItems.length <= 1) return setEditItems(prev => prev.filter((_, i) => i !== index)) } const handleEditDragEnd = (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return setEditItems(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 saveEditItems = async () => { setSaving(true) try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: editItems.filter(i => i.description.trim()).map((item, i) => ({ ...item, position: i })) }) }) const result = await response.json() if (result.success) { alert.success('Položky byly uloženy') setEditingItems(false) fetchDetail() } else { alert.error(result.error || 'Nepodařilo se uložit položky') } } catch { alert.error('Chyba připojení') } finally { setSaving(false) } } // ─── Edit mode: delete ─── const handleDelete = async () => { setDeleting(true) try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: 'DELETE' }) const result = await response.json() if (result.success) { alert.success(result.message || 'Faktura byla smazána') navigate('/invoices') } else { alert.error(result.error || 'Nepodařilo se smazat fakturu') } } catch { alert.error('Chyba připojení') } finally { setDeleting(false) setDeleteConfirm(false) } } // ─── Permission checks ─── if (!isEdit && !hasPermission('invoices.create')) return if (isEdit && !hasPermission('invoices.view')) return // ─── Loading skeleton ─── if (loading) { return (
{isEdit &&
}
{isEdit && (
)}
{[0, 1, 2, 3].map(i => (
))}
) } // ═══════════════════════════════════════════════════════════ // CREATE MODE // ═══════════════════════════════════════════════════════════ if (!isEdit) { return (

Nová faktura {invoiceNumber && ({invoiceNumber})}

{fromOrderId &&

Z objednávky

}
{/* Basic info */}

Základní údaje

setInvoiceNumber(e.target.value)} className="admin-form-input" /> {form.customer_id ? (
{form.customer_name}
) : (
e.stopPropagation()}> { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }} onFocus={() => setShowCustomerDropdown(true)} className="admin-form-input" placeholder="Hledat zákazníka (název, IČ, město)..." autoComplete="off" /> {showCustomerDropdown && (
{filteredCustomers.length === 0 ? (
Žádní zákazníci
) : ( filteredCustomers.slice(0, 10).map(c => (
selectCustomer(c)}>
{c.name}
{(c.company_id || c.city) && (
{c.company_id && `IČ: ${c.company_id}`}{c.city && ` · ${c.city}`}
)}
)) )}
)}
)}
setForm(prev => ({ ...prev, billing_text: e.target.value }))} className="admin-form-input" placeholder="Fakturujeme Vám za: (ponechte prázdné pro výchozí)" />
{ setForm(prev => ({ ...prev, issue_date: val })); setErrors(prev => ({ ...prev, issue_date: '' })) }} /> {form.due_date && ( Splatnost: {new Date(form.due_date).toLocaleDateString('cs-CZ')} )} { setForm(prev => ({ ...prev, tax_date: val })); setErrors(prev => ({ ...prev, tax_date: '' })) }} />
{/* Items */}

Položky

{errors.items && {errors.items}}
i._key)} strategy={verticalListSortingStrategy}>
{form.apply_vat ? : null} {items.map((item, index) => ( 1} /> ))}
# Popis Množství Jednotka Jedn. cenaDPHCelkem
{/* Totals */}
Mezisoučet: {formatCurrency(createTotals.subtotal, form.currency)}
{form.apply_vat && Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
DPH {rate}%: {formatCurrency(amount, form.currency)}
))}
Celkem k úhradě: {formatCurrency(createTotals.total, form.currency)}
{/* Notes */}

Veřejné poznámky na faktuře