import { useState, useEffect, useCallback, useRef } from 'react' import { useAlert } from '../context/AlertContext' import { useAuth } from '../context/AuthContext' import { motion, AnimatePresence } from 'framer-motion' import ConfirmModal from '../components/ConfirmModal' import FormField from '../components/FormField' import apiFetch from '../utils/api' import { formatCurrency, formatDate, czechPlural } from '../utils/formatters' import SortIcon from '../components/SortIcon' import useTableSort from '../hooks/useTableSort' import useModalLock from '../hooks/useModalLock' import AdminDatePicker from '../components/AdminDatePicker' const API_BASE = '/api/admin' const STATUS_LABELS: Record = { unpaid: 'Neuhrazena', paid: 'Uhrazena' } const STATUS_CLASSES: Record = { unpaid: 'admin-badge-invoice-overdue', paid: 'admin-badge-invoice-paid' } const CURRENCY_OPTIONS = ['CZK', 'EUR', 'USD', 'GBP'] const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21] const MONTH_NAMES = [ 'leden', 'únor', 'březen', 'duben', 'květen', 'červen', 'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec' ] interface CurrencyAmount { amount: number currency: string } interface ReceivedInvoice { id: number supplier_name: string invoice_number: string amount: number currency: string vat_rate: number issue_date: string due_date: string notes: string status: string file_name?: string created_at: string } interface ReceivedStats { total_month: CurrencyAmount[] total_month_czk: number | null vat_month: CurrencyAmount[] vat_month_czk: number | null unpaid: CurrencyAmount[] unpaid_czk: number | null unpaid_count: number month_count: number } interface UploadMeta { supplier_name: string invoice_number: string amount: string currency: string vat_rate: string issue_date: string due_date: string notes: string } interface EditInvoice extends Omit { amount: string vat_rate: string _originalStatus: string } interface UploadErrors { [idx: number]: { [field: string]: string } } interface ReceivedInvoicesProps { statsMonth: number statsYear: number uploadOpen: boolean setUploadOpen: (open: boolean) => void } function formatMultiCurrency(amounts: CurrencyAmount[]): string { if (!Array.isArray(amounts) || amounts.length === 0) { return '0 Kč' } return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ') } function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } { if (!Array.isArray(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 } } function emptyMeta(): UploadMeta { return { supplier_name: '', invoice_number: '', amount: '', currency: 'CZK', vat_rate: '21', issue_date: '', due_date: '', notes: '', } } export default function ReceivedInvoices({ statsMonth, statsYear, uploadOpen, setUploadOpen }: ReceivedInvoicesProps) { const alert = useAlert() const { hasPermission } = useAuth() const { sort, order, handleSort, activeSort } = useTableSort('created_at') const [search, setSearch] = useState('') // Data const [invoices, setInvoices] = useState([]) const [loading, setLoading] = useState(true) const [stats, setStats] = useState(null) const [statsLoading, setStatsLoading] = useState(true) const hasLoadedOnce = useRef(false) const slideDirection = useRef(0) const [slideKey, setSlideKey] = useState(0) const prevMonth = useRef(statsMonth) const prevYear = useRef(statsYear) // Modals const [editOpen, setEditOpen] = useState(false) const [editInvoice, setEditInvoice] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: ReceivedInvoice | null }>({ show: false, invoice: null }) const [deleting, setDeleting] = useState(false) const [saving, setSaving] = useState(false) // Supplier autocomplete const [supplierNames, setSupplierNames] = useState([]) // Upload state const [uploadFiles, setUploadFiles] = useState([]) const [uploadMeta, setUploadMeta] = useState([]) const [uploadErrors, setUploadErrors] = useState({}) const fileInputRef = useRef(null) useModalLock(uploadOpen || editOpen) // Slide direction detection useEffect(() => { const prev = prevYear.current * 12 + prevMonth.current const curr = statsYear * 12 + statsMonth if (curr > prev) { slideDirection.current = 1 } if (curr < prev) { slideDirection.current = -1 } prevMonth.current = statsMonth prevYear.current = statsYear }, [statsMonth, statsYear]) // Fetch list const fetchList = useCallback(async () => { if (!hasLoadedOnce.current) setLoading(true) try { const params = new URLSearchParams({ month: String(statsMonth), year: String(statsYear), }) if (search) { params.set('search', search) } if (sort) { params.set('sort', sort) } if (order) { params.set('order', order) } const res = await apiFetch(`${API_BASE}/received-invoices?${params}`) const data = await res.json() if (data.success) { setInvoices(Array.isArray(data.data) ? data.data : []) } } catch { /* ignore */ } finally { setLoading(false) hasLoadedOnce.current = true } }, [statsMonth, statsYear, search, sort, order]) useEffect(() => { fetchList() }, [fetchList]) // Fetch supplier names for autocomplete useEffect(() => { apiFetch(`${API_BASE}/received-invoices/suppliers`) .then(r => r.json()) .then(d => { if (d.success) setSupplierNames(d.data || []) }) .catch(() => {}) }, []) // Fetch stats (silent refresh without animation) const refreshStats = useCallback(async () => { try { const res = await apiFetch(`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`) const data = await res.json() if (data.success) { setStats(data.data) hasLoadedOnce.current = true } } catch { /* ignore */ } }, [statsMonth, statsYear]) // Fetch stats on month change (with slide animation) useEffect(() => { setStatsLoading(true) const load = async () => { try { const res = await apiFetch(`${API_BASE}/received-invoices/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) } } load() }, [statsMonth, statsYear]) // Upload handlers const handleFileSelect = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files || []) if (selected.length === 0) { return } if (uploadFiles.length + selected.length > 20) { alert.error('Maximálně 20 souborů najednou') return } const valid = selected.filter(f => { if (f.size > 10 * 1024 * 1024) { alert.error(`Soubor "${f.name}" je větší než 10 MB`) return false } const allowed = ['application/pdf', 'image/jpeg', 'image/png'] if (!allowed.includes(f.type)) { alert.error(`Soubor "${f.name}": nepodporovaný formát`) return false } return true }) setUploadFiles(prev => [...prev, ...valid]) setUploadMeta(prev => [...prev, ...valid.map(() => emptyMeta())]) e.target.value = '' } const removeUploadFile = (idx: number) => { setUploadFiles(prev => prev.filter((_, i) => i !== idx)) setUploadMeta(prev => prev.filter((_, i) => i !== idx)) const newErrors = { ...uploadErrors } delete newErrors[idx] setUploadErrors(newErrors) } const updateMeta = (idx: number, field: keyof UploadMeta, value: string) => { setUploadMeta(prev => prev.map((m, i) => i === idx ? { ...m, [field]: value } : m)) if (uploadErrors[idx]) { const newErrors = { ...uploadErrors } if (newErrors[idx]?.[field]) { delete newErrors[idx][field] if (Object.keys(newErrors[idx]).length === 0) { delete newErrors[idx] } } setUploadErrors(newErrors) } } const validateUpload = (): boolean => { const errors: UploadErrors = {} uploadMeta.forEach((m, i) => { const e: Record = {} if (!m.supplier_name.trim()) { e.supplier_name = 'Povinné pole' } if (!m.amount || parseFloat(m.amount) <= 0) { e.amount = 'Částka musí být větší než 0' } if (Object.keys(e).length > 0) { errors[i] = e } }) setUploadErrors(errors) return Object.keys(errors).length === 0 } const handleUploadSave = async () => { if (uploadFiles.length === 0) { alert.error('Vyberte alespoň jeden soubor') return } if (!validateUpload()) { return } setSaving(true) try { const formData = new FormData() uploadFiles.forEach(f => formData.append('files[]', f)) formData.append('invoices', JSON.stringify(uploadMeta)) const res = await apiFetch(`${API_BASE}/received-invoices`, { method: 'POST', body: formData, }) const data = await res.json() if (data.success) { alert.success(data.message || 'Faktury byly nahrány') setUploadOpen(false) setUploadFiles([]) setUploadMeta([]) setUploadErrors({}) fetchList() refreshStats() } else { alert.error(data.error || 'Chyba při nahrávání') } } catch { alert.error('Chyba připojení') } finally { setSaving(false) } } // Edit handlers const toDateInput = (d: string | null | undefined): string => { if (!d) return '' const date = new Date(d) if (isNaN(date.getTime())) return '' return date.toISOString().split('T')[0] } const openEdit = (inv: ReceivedInvoice) => { setEditInvoice({ ...inv, amount: String(inv.amount), vat_rate: String(inv.vat_rate), issue_date: toDateInput(inv.issue_date), due_date: toDateInput(inv.due_date), _originalStatus: inv.status, }) setEditOpen(true) } const handleEditSave = async () => { if (!editInvoice) { return } if (!editInvoice.supplier_name?.trim()) { alert.error('Dodavatel je povinný') return } if (!editInvoice.amount || parseFloat(editInvoice.amount) <= 0) { alert.error('Částka musí být větší než 0') return } setSaving(true) try { const payload = { supplier_name: editInvoice.supplier_name, invoice_number: editInvoice.invoice_number || '', amount: parseFloat(editInvoice.amount), currency: editInvoice.currency, vat_rate: parseFloat(editInvoice.vat_rate), issue_date: editInvoice.issue_date || '', due_date: editInvoice.due_date || '', notes: editInvoice.notes || '', status: editInvoice.status, } const res = await apiFetch(`${API_BASE}/received-invoices/${editInvoice.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const data = await res.json() if (data.success) { alert.success(data.message || 'Faktura byla aktualizována') setEditOpen(false) setEditInvoice(null) fetchList() refreshStats() } else { alert.error(data.error || 'Chyba při ukládání') } } catch { alert.error('Chyba připojení') } finally { setSaving(false) } } // Delete const handleDelete = async () => { if (!deleteConfirm.invoice) { return } setDeleting(true) try { const res = await apiFetch(`${API_BASE}/received-invoices/${deleteConfirm.invoice.id}`, { method: 'DELETE', }) const data = await res.json() if (data.success) { alert.success(data.message || 'Faktura byla smazána') setDeleteConfirm({ show: false, invoice: null }) fetchList() refreshStats() } else { alert.error(data.error || 'Chyba při mazání') } } catch { alert.error('Chyba připojení') } finally { setDeleting(false) } } // View file const openFile = async (inv: ReceivedInvoice) => { const newWindow = window.open('', '_blank') try { const response = await apiFetch(`${API_BASE}/received-invoices/${inv.id}/file`) if (!response.ok) { newWindow?.close() alert.error('Nepodařilo se načíst soubor') return } const blob = await response.blob() const url = URL.createObjectURL(blob) if (newWindow) { newWindow.location.href = url } setTimeout(() => URL.revokeObjectURL(url), 60000) } catch { newWindow?.close() alert.error('Chyba připojení') } } const toggleStatus = async (inv: ReceivedInvoice) => { if (inv.status === 'paid') return try { const res = await apiFetch(`${API_BASE}/received-invoices/${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 uhrazená') fetchList() refreshStats() } else { alert.error(data.error || 'Nepodařilo se změnit stav') } } catch { alert.error('Chyba připojení') } } const monthLabel = `${MONTH_NAMES[statsMonth - 1]}` // KPI const renderKpi = () => { if (!hasLoadedOnce.current && statsLoading) { return (
{[0, 1, 2, 3].map(i => (
))}
) } if (!stats) { return null } const total = formatCzkWithDetail(stats.total_month, stats.total_month_czk) const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk) const unpaid = formatCzkWithDetail(stats.unpaid, stats.unpaid_czk) return (
({ x: `${(dir || 0) * 105}%`, opacity: 0 }), center: { x: '0%', opacity: 1 }, exit: (dir: number) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={{ type: 'spring', stiffness: 300, damping: 30 }} >
Celkem ({monthLabel})
{total.value}
{total.detail || `${stats.month_count} ${czechPlural(stats.month_count, 'faktura', 'faktury', 'faktur')}`}
DPH k odpočtu ({monthLabel})
{vat.value}
{vat.detail || 'z přijatých faktur'}
Neuhrazeno · celkově
{unpaid.value}
{unpaid.detail || (stats.unpaid_count === 0 ? 'vše uhrazeno' : `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, 'faktura', 'faktury', 'faktur')}` )}
Počet ({monthLabel})
{stats.month_count}
{stats.month_count === 0 ? 'žádné faktury' : `přijatých faktur`}
) } return ( <> {renderKpi()}
setSearch(e.target.value)} className="admin-form-input" placeholder="Hledat podle dodavatele nebo čísla faktury..." />
{loading && (
{[0, 1, 2, 3, 4].map(i => (
))}
)} {!loading && invoices.length === 0 && (

Žádné přijaté faktury v tomto měsíci.

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

Nahrajte faktury tlačítkem výše.

)}
)} {!loading && invoices.length > 0 && (
{invoices.map((inv) => ( ))}
handleSort('supplier_name')}> Dodavatel handleSort('invoice_number')}> Č. faktury handleSort('status')}> Stav handleSort('issue_date')}> Vystaveno handleSort('due_date')}> Splatnost handleSort('amount')}> Částka Akce
{inv.supplier_name} {inv.invoice_number ? ( openFile(inv)}> {inv.invoice_number} ) : '—'} {inv.status === 'paid' ? ( {STATUS_LABELS[inv.status]} ) : ( )} {formatDate(inv.issue_date)} {formatDate(inv.due_date)} {formatCurrency(inv.amount, inv.currency)}
{inv.file_name && ( )} {hasPermission('invoices.edit') && ( )} {hasPermission('invoices.delete') && ( )}
)}
{/* Upload Modal */} {uploadOpen && (
!saving && setUploadOpen(false)} />

Nahrát přijaté faktury

PDF, JPEG, PNG · max 10 MB · max 20 souborů
{uploadFiles.length === 0 && (

Zatím nebyly vybrány žádné soubory.

)}
{uploadFiles.map((file, idx) => (
{file.name} {Math.round(file.size / 1024)} KB
updateMeta(idx, 'supplier_name', e.target.value)} autoComplete="off" /> updateMeta(idx, 'invoice_number', e.target.value)} />
updateMeta(idx, 'amount', e.target.value)} />
{uploadMeta[idx]?.amount && (
DPH: {formatCurrency( (() => { const a = parseFloat(uploadMeta[idx].amount || '0'); const r = parseFloat(uploadMeta[idx].vat_rate || '21'); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(), uploadMeta[idx].currency || 'CZK' )}
)}
updateMeta(idx, 'issue_date', val)} /> updateMeta(idx, 'due_date', val)} />
updateMeta(idx, 'notes', e.target.value)} />
))}
)} {/* Edit Modal */} {editOpen && editInvoice && (
!saving && setEditOpen(false)} /> {(() => { const ro = editInvoice._originalStatus === 'paid' return ( <>

{ro ? 'Detail přijaté faktury' : 'Upravit přijatou fakturu'}

setEditInvoice(prev => prev ? { ...prev, supplier_name: e.target.value } : null)} readOnly={ro} autoComplete="off" /> setEditInvoice(prev => prev ? { ...prev, invoice_number: e.target.value } : null)} readOnly={ro} />
setEditInvoice(prev => prev ? { ...prev, amount: e.target.value } : null)} readOnly={ro} />
{editInvoice.amount && (
DPH: {formatCurrency( (() => { const a = parseFloat(editInvoice.amount || '0'); const r = parseFloat(editInvoice.vat_rate || '21'); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(), editInvoice.currency || 'CZK' )}
)}
setEditInvoice(prev => prev ? { ...prev, issue_date: val } : null)} disabled={ro} /> setEditInvoice(prev => prev ? { ...prev, due_date: val } : null)} disabled={ro} />