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 DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"]; const DEFAULT_VAT_RATES = [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 }; } interface CompanySettings { default_currency: string; default_vat_rate: number; available_currencies: string[]; available_vat_rates: number[]; } function emptyMeta(settings: CompanySettings | null): UploadMeta { return { supplier_name: "", invoice_number: "", amount: "", currency: settings?.default_currency || "CZK", vat_rate: String(settings?.default_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(""); 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); 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); const [supplierNames, setSupplierNames] = useState([]); const [companySettings, setCompanySettings] = useState(null); const [uploadFiles, setUploadFiles] = useState([]); const [uploadMeta, setUploadMeta] = useState([]); const [uploadErrors, setUploadErrors] = useState({}); const fileInputRef = useRef(null); useModalLock(uploadOpen || editOpen); 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]); 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]); useEffect(() => { apiFetch(`${API_BASE}/received-invoices/suppliers`) .then((r) => r.json()) .then((d) => { if (d.success) setSupplierNames(d.data || []); }) .catch(() => {}); }, []); useEffect(() => { apiFetch(`${API_BASE}/company-settings`) .then((r) => r.json()) .then((d) => { if (d.success) setCompanySettings(d.data); }) .catch(() => {}); }, []); const currencyOptions = companySettings?.available_currencies || DEFAULT_CURRENCIES; const vatRateOptions = companySettings?.available_vat_rates || DEFAULT_VAT_RATES; const defaultCurrency = companySettings?.default_currency || "CZK"; const defaultVatRate = String(companySettings?.default_vat_rate ?? 21); // 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]); 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(companySettings)), ]); 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); } }; 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); } }; 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); } }; 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]}`; 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 || defaultVatRate, ); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(), uploadMeta[idx].currency || defaultCurrency, )}
)}
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 || defaultVatRate, ); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(), editInvoice.currency || defaultCurrency, )}
)}
setEditInvoice((prev) => prev ? { ...prev, issue_date: val } : null, ) } disabled={ro} /> setEditInvoice((prev) => prev ? { ...prev, due_date: val } : null, ) } disabled={ro} />