import { useState, useEffect, useCallback, useRef, lazy, Suspense, } from "react"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { Link, useSearchParams } 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", ]; interface CurrencyAmount { amount: number; currency: string; } 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) { return { value: formatCurrency(totalCzk, "CZK"), detail: formatMultiCurrency(amounts), }; } return { value: formatMultiCurrency(amounts), detail: null }; } 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 STATUS_FILTERS = [ { value: "", label: "Vše" }, { value: "issued", label: "Vystavené" }, { value: "paid", label: "Zaplacené" }, { value: "overdue", label: "Po splatnosti" }, ]; interface Invoice { id: number; invoice_number: string; customer_name: string | null; status: string; issue_date: string; due_date: string; total: number; currency: string; } interface InvoiceStats { paid_month: CurrencyAmount[]; paid_month_czk: number; paid_month_count: number; awaiting: CurrencyAmount[]; awaiting_czk: number; awaiting_count: number; overdue: CurrencyAmount[]; overdue_czk: number; overdue_count: number; vat_month: CurrencyAmount[]; vat_month_czk: number; } interface DraftData { form: Record; items: Record[]; savedAt?: string; } export default function Invoices() { const alert = useAlert(); const { hasPermission } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const activeTab = searchParams.get("tab") === "received" ? "received" : "issued"; const setActiveTab = (tab: string) => setSearchParams({ tab }, { replace: true }); 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/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: boolean; invoice: Invoice | null; }>({ show: false, invoice: null }); const [deleting, setDeleting] = useState(false); const [pdfLoading, setPdfLoading] = useState(null); const [langModal, setLangModal] = useState(null); const [draft, setDraft] = useState(() => { try { const raw = localStorage.getItem(DRAFT_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as DraftData; if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed; } catch { /* ignore */ } return null; }); const discardDraft = () => { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } setDraft(null); }; const { items: invoices, loading, initialLoad, pagination, refetch: fetchData, } = useListData("invoices", { search, sort, order, page, extraParams: { month: String(statsMonth), year: String(statsYear), ...(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/${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: Invoice) => { if (inv.status === "paid") return; try { const res = await apiFetch(`${API_BASE}/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 zaplacená"); fetchData(); fetchStats(); } else { alert.error(data.error || "Nepodařilo se změnit stav"); } } catch { alert.error("Chyba připojení"); } }; const handlePdf = async (inv: Invoice, lang = "cs") => { if (pdfLoading) return; setLangModal(null); setPdfLoading(inv.id); try { const response = await apiFetch( `${API_BASE}/invoices-pdf/${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 (initialLoad) { 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: number) => ({ 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: number, zero: string) => 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 as string) || "\u2014"} {"\u2014"} {draft.form.issue_date ? formatDate(draft.form.issue_date as string) : "\u2014"} {draft.form.due_date ? formatDate(draft.form.due_date as string) : "\u2014"}
{inv.invoice_number} {inv.customer_name || "\u2014"} {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?

)} )}
); }