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 = { unpaid: 'Neuhrazena', paid: 'Uhrazena' } const STATUS_CLASSES = { 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' ] 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 } } function emptyMeta() { return { supplier_name: '', invoice_number: '', amount: '', currency: 'CZK', vat_rate: '21', issue_date: '', due_date: '', notes: '', } } ReceivedInvoicesProps.displayName = 'ReceivedInvoices' export default function ReceivedInvoicesProps({ statsMonth, statsYear, uploadOpen, setUploadOpen }) { 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: false, invoice: null }) const [deleting, setDeleting] = useState(false) const [saving, setSaving] = useState(false) // 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 () => { 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.php?${params}`) const data = await res.json() if (data.success) { setInvoices(data.data.invoices || []) } } catch { /* ignore */ } finally { setLoading(false) } }, [statsMonth, statsYear, search, sort, order]) useEffect(() => { fetchList() }, [fetchList]) // Fetch stats (tiché obnovení bez animace) const refreshStats = useCallback(async () => { try { const res = await apiFetch(`${API_BASE}/received-invoices.php?action=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 při změně měsíce (se slide animací) useEffect(() => { setStatsLoading(true) const load = async () => { try { const res = await apiFetch(`${API_BASE}/received-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) } } load() }, [statsMonth, statsYear]) // Upload handlers const handleFileSelect = (e) => { 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) => { 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, field, value) => { 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 = () => { const errors = {} uploadMeta.forEach((m, i) => { const e = {} 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.php`, { 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 openEdit = (inv) => { setEditInvoice({ ...inv, amount: String(inv.amount), vat_rate: String(inv.vat_rate), _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.php?id=${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.php?id=${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) => { const newWindow = window.open('', '_blank') try { const response = await apiFetch(`${API_BASE}/received-invoices.php?action=file&id=${inv.id}`) if (!response.ok) { newWindow.close() alert.error('Nepodařilo se načíst soubor') return } const blob = await response.blob() const url = URL.createObjectURL(blob) newWindow.location.href = url setTimeout(() => URL.revokeObjectURL(url), 60000) } catch { newWindow.close() alert.error('Chyba připojení') } } const toggleStatus = async (inv) => { if (inv.status === 'paid') return try { const res = await apiFetch(`${API_BASE}/received-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 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) => ({ 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].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)} /> updateMeta(idx, 'invoice_number', e.target.value)} />
updateMeta(idx, 'amount', e.target.value)} />
{uploadMeta[idx]?.amount && (
DPH: {formatCurrency( parseFloat(uploadMeta[idx].amount || 0) * parseFloat(uploadMeta[idx].vat_rate || 21) / 100, 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, supplier_name: e.target.value }))} readOnly={ro} /> setEditInvoice(prev => ({ ...prev, invoice_number: e.target.value }))} readOnly={ro} />
setEditInvoice(prev => ({ ...prev, amount: e.target.value }))} readOnly={ro} />
{editInvoice.amount && (
DPH: {formatCurrency( parseFloat(editInvoice.amount || 0) * parseFloat(editInvoice.vat_rate || 21) / 100, editInvoice.currency || 'CZK' )}
)}
setEditInvoice(prev => ({ ...prev, issue_date: val }))} disabled={ro} /> setEditInvoice(prev => ({ ...prev, due_date: val }))} disabled={ro} />