import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react' import { useAlert } from '../context/AlertContext' import { useAuth } from '../context/AuthContext' import { Link } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import ConfirmModal from '../components/ConfirmModal' import Forbidden from '../components/Forbidden' import apiFetch from '../utils/api' import { formatCurrency, formatDate, czechPlural } from '../utils/formatters' import SortIcon from '../components/SortIcon' import useTableSort from '../hooks/useTableSort' import useListData from '../hooks/useListData' import Pagination from '../components/Pagination' const ReceivedInvoices = lazy(() => import('./ReceivedInvoices')) const API_BASE = '/api/admin' const DRAFT_KEY = 'boha_invoice_draft' const MONTH_NAMES = [ 'leden', 'únor', 'březen', 'duben', 'květen', 'červen', 'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec' ] function formatMultiCurrency(amounts) { if (!amounts || amounts.length === 0) return '0 Kč' return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ') } function formatCzkWithDetail(amounts, totalCzk) { if (!amounts || amounts.length === 0) return { value: '0 Kč', detail: null } const hasForeign = amounts.some(a => a.currency !== 'CZK') if (hasForeign && totalCzk !== null && totalCzk !== undefined) { return { value: formatCurrency(totalCzk, 'CZK'), detail: formatMultiCurrency(amounts), } } return { value: formatMultiCurrency(amounts), detail: null } } const STATUS_LABELS = { issued: 'Vystavena', paid: 'Zaplacena', overdue: 'Po splatnosti' } const STATUS_CLASSES = { issued: 'admin-badge-invoice-issued', paid: 'admin-badge-invoice-paid', overdue: 'admin-badge-invoice-overdue' } const STATUS_FILTERS = [ { value: '', label: 'Vše' }, { value: 'issued', label: 'Vystavené' }, { value: 'paid', label: 'Zaplacené' }, { value: 'overdue', label: 'Po splatnosti' } ] export default function Invoices() { const alert = useAlert() const { hasPermission } = useAuth() const [activeTab, setActiveTab] = useState('issued') const [receivedUploadOpen, setReceivedUploadOpen] = useState(false) const { sort, order, handleSort, activeSort } = useTableSort('invoice_number') const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [statusFilter, setStatusFilter] = useState('') const now = new Date() const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1) const [statsYear, setStatsYear] = useState(now.getFullYear()) const [stats, setStats] = useState(null) const [statsLoading, setStatsLoading] = useState(true) const hasLoadedOnce = useRef(false) const slideDirection = useRef(0) const [slideKey, setSlideKey] = useState(0) const isCurrentMonth = statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear() const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}` const fetchStats = useCallback(async () => { setStatsLoading(true) try { const res = await apiFetch(`${API_BASE}/invoices.php?action=stats&month=${statsMonth}&year=${statsYear}`) const data = await res.json() if (data.success) { setStats(data.data) hasLoadedOnce.current = true setSlideKey(k => k + 1) } } catch { /* ignore */ } finally { setStatsLoading(false) } }, [statsMonth, statsYear]) useEffect(() => { fetchStats() }, [fetchStats]) const prevMonth = () => { slideDirection.current = -1 if (statsMonth === 1) { setStatsMonth(12) setStatsYear(y => y - 1) } else { setStatsMonth(m => m - 1) } } const nextMonth = () => { if (isCurrentMonth) return slideDirection.current = 1 if (statsMonth === 12) { setStatsMonth(1) setStatsYear(y => y + 1) } else { setStatsMonth(m => m + 1) } } const [deleteConfirm, setDeleteConfirm] = useState({ show: false, invoice: null }) const [deleting, setDeleting] = useState(false) const [pdfLoading, setPdfLoading] = useState(null) const [langModal, setLangModal] = useState(null) const [draft, setDraft] = useState(null) useEffect(() => { try { const raw = localStorage.getItem(DRAFT_KEY) if (!raw) return const parsed = JSON.parse(raw) if (parsed && parsed.form && Array.isArray(parsed.items)) { setDraft(parsed) } } catch { /* ignore */ } }, []) const discardDraft = () => { try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ } setDraft(null) } const { items: invoices, loading, pagination, refetch: fetchData } = useListData('invoices.php', { dataKey: 'invoices', search, sort, order, page, extraParams: statusFilter ? { status: statusFilter } : {}, errorMsg: 'Nepodařilo se načíst faktury' }) if (!hasPermission('invoices.view')) return const handleDelete = async () => { if (!deleteConfirm.invoice) return setDeleting(true) try { const response = await apiFetch(`${API_BASE}/invoices.php?id=${deleteConfirm.invoice.id}`, { method: 'DELETE' }) const result = await response.json() if (result.success) { setDeleteConfirm({ show: false, invoice: null }) alert.success(result.message || 'Faktura byla smazána') fetchData() fetchStats() } else { alert.error(result.error || 'Nepodařilo se smazat fakturu') } } catch { alert.error('Chyba připojení') } finally { setDeleting(false) } } const toggleStatus = async (inv) => { if (inv.status === 'paid') return try { const res = await apiFetch(`${API_BASE}/invoices.php?id=${inv.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'paid' }), }) const data = await res.json() if (data.success) { alert.success('Faktura označena jako zaplacená') fetchData() fetchStats() } else { alert.error(data.error || 'Nepodařilo se změnit stav') } } catch { alert.error('Chyba připojení') } } const handlePdf = async (inv, lang = 'cs') => { if (pdfLoading) return setLangModal(null) setPdfLoading(inv.id) try { const response = await apiFetch(`${API_BASE}/invoices-pdf.php?id=${inv.id}&lang=${encodeURIComponent(lang)}`) 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(null) } } if (loading) { return (
{[0, 1, 2, 3].map(i => (
))}
{[0, 1, 2, 3, 4].map(i => (
))}
) } return (

Faktury

{pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? invoices.length, 'faktura', 'faktury', 'faktur')}

{hasPermission('invoices.create') && (
{activeTab === 'received' ? ( ) : ( Nová faktura )}
)}
{monthLabel}
{activeTab === 'received' ? ( {[0, 1, 2, 3].map(i => (
))}
}> ) : ( <> {!hasLoadedOnce.current && statsLoading ? (
{[0, 1, 2, 3].map(i => (
))}
) : stats && (
({ x: `${(dir || 0) * 105}%`, opacity: 0 }), center: { x: '0%', opacity: 1 }, exit: (dir) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={{ type: 'spring', stiffness: 300, damping: 30 }} > {(() => { const paid = formatCzkWithDetail(stats.paid_month, stats.paid_month_czk) const wait = formatCzkWithDetail(stats.awaiting, stats.awaiting_czk) const over = formatCzkWithDetail(stats.overdue, stats.overdue_czk) const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk) const countFooter = (count, zero) => count > 0 ? `${count} ${czechPlural(count, 'faktura', 'faktury', 'faktur')}` : zero return ( <>
Uhrazeno ({MONTH_NAMES[statsMonth - 1]})
{paid.value}
{[paid.detail, countFooter(stats.paid_month_count, 'žádné úhrady')].filter(Boolean).join(' · ')}
Čeká úhrada · celkově
{wait.value}
{[wait.detail, countFooter(stats.awaiting_count, 'vše uhrazeno')].filter(Boolean).join(' · ')}
Po splatnosti · celkově
{over.value}
{[over.detail, stats.overdue_count === 0 ? 'vše v pořádku' : countFooter(stats.overdue_count, '')].filter(Boolean).join(' · ')}
DPH ({MONTH_NAMES[statsMonth - 1]})
{vat.value}
{vat.detail || 'z vydaných faktur'}
) })()}
)}
{STATUS_FILTERS.map(f => ( ))}
{ setSearch(e.target.value); setPage(1) }} className="admin-form-input" placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..." />
{invoices.length === 0 && !(draft && !statusFilter) ? (

Zatím nejsou žádné faktury.

{hasPermission('invoices.create') && (

Vytvořte první fakturu tlačítkem výše.

)}
) : (
{draft && !search && !statusFilter && ( )} {invoices.map((inv) => { const isOverdue = inv.status === 'overdue' || (inv.status === 'issued' && inv.due_date && new Date(inv.due_date) < new Date(new Date().toDateString())) return ( ) })}
handleSort('invoice_number')}> Číslo Zákazník handleSort('status')}> Stav handleSort('issue_date')}> Vystaveno handleSort('due_date')}> Splatnost Celkem Akce
Koncept {draft.savedAt && ( {' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })} )} {draft.form.customer_name || '—'} {draft.form.issue_date ? formatDate(draft.form.issue_date) : '—'} {draft.form.due_date ? formatDate(draft.form.due_date) : '—'}
{inv.invoice_number} {inv.customer_name || '—'} {inv.status === 'paid' ? ( {STATUS_LABELS[inv.status]} ) : ( )} {formatDate(inv.issue_date)} {formatDate(inv.due_date)} {formatCurrency(inv.total, inv.currency)}
{hasPermission('invoices.export') && ( )} {hasPermission('invoices.delete') && ( )}
)}
setDeleteConfirm({ show: false, invoice: null })} onConfirm={handleDelete} title="Smazat fakturu" message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" type="danger" loading={deleting} /> {langModal && (
setLangModal(null)} />

Jazyk faktury

V jakém jazyce chcete vygenerovat fakturu?

)} )}
) }