import { useState, useEffect, useRef, lazy, Suspense } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { Link as RouterLink, useSearchParams } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, czechPlural, todayLocalStr, } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import useTableSort from "../hooks/useTableSort"; import { invoiceListOptions, invoiceStatsOptions, type Invoice, type CurrencyAmount, } from "../lib/queries/invoices"; import { Button, Card, DataTable, Pagination, ConfirmDialog, TextField, StatusChip, StatCard, Alert, EmptyState, LoadingState, FilterBar, Tabs, PageHeader, PageEnter, type DataColumn, type TabDef, type StatCardColor, } from "../ui"; 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: 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_COLORS: Record< string, "default" | "success" | "warning" | "error" > = { issued: "warning", paid: "success", overdue: "error", }; const STATUS_FILTERS = [ { value: "", label: "Vše" }, { value: "issued", label: "Vystavené" }, { value: "paid", label: "Zaplacené" }, { value: "overdue", label: "Po splatnosti" }, ]; interface DraftData { form: Record; items: Record[]; savedAt?: string; } const PlusIcon = ( ); const UploadIcon = ( ); const ChevronLeftIcon = ( ); const ChevronRightIcon = ( ); const ViewIcon = ( ); const EditIcon = ( ); const PdfIcon = ( ); const DeleteIcon = ( ); 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 } = 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 blobUrlRef = useRef(null); useEffect(() => { return () => { if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; }, []); const isCurrentMonth = statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear(); const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`; const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear)); const stats = statsQuery.data ?? null; const prevMonth = () => { if (statsMonth === 1) { setStatsMonth(12); setStatsYear((y) => y - 1); } else { setStatsMonth((m) => m - 1); } }; const nextMonth = () => { if (isCurrentMonth) return; 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 [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 queryClient = useQueryClient(); const { items: invoices, pagination, isPending: initialLoad, isFetching: loading, } = usePaginatedQuery( invoiceListOptions({ search, sort, order, page, month: statsMonth, year: statsYear, status: statusFilter || undefined, }), ); 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"); queryClient.invalidateQueries({ queryKey: ["invoices"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); } 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á"); queryClient.invalidateQueries({ queryKey: ["invoices"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); } else { alert.error(data.error || "Nepodařilo se změnit stav"); } } catch { alert.error("Chyba připojení"); } }; const handlePdf = async (inv: Invoice) => { if (pdfLoading) return; const newWindow = window.open("", "_blank"); setPdfLoading(inv.id); try { const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`); if (response.status === 401) { newWindow?.close(); return; } if (!response.ok) { newWindow?.close(); alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji"); return; } const blob = await response.blob(); if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); } blobUrlRef.current = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = blobUrlRef.current; } catch { alert.error("Chyba při generování PDF"); } finally { setPdfLoading(null); } }; if (initialLoad) { return ; } const total = pagination?.total ?? invoices.length; const subtitle = `${total} ${czechPlural( total, "faktura", "faktury", "faktur", )}`; const statusTabs: TabDef[] = STATUS_FILTERS.map((f) => ({ value: f.value, label: f.label, })); // KPI cards — stat math preserved verbatim. const renderKpi = () => { if (statsQuery.isPending && !stats) { return ; } if (!stats) return null; 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; const cards: { label: string; value: string; footer: string; color: StatCardColor; }[] = [ { label: `Uhrazeno (${MONTH_NAMES[statsMonth - 1]})`, value: paid.value, footer: [ paid.detail, countFooter(stats.paid_month_count, "žádné úhrady"), ] .filter(Boolean) .join(" · "), color: "success", }, { label: "Čeká úhrada · celkově", value: wait.value, footer: [wait.detail, countFooter(stats.awaiting_count, "vše uhrazeno")] .filter(Boolean) .join(" · "), color: "warning", }, { label: "Po splatnosti · celkově", value: over.value, footer: [ over.detail, stats.overdue_count === 0 ? "vše v pořádku" : countFooter(stats.overdue_count, ""), ] .filter(Boolean) .join(" · "), color: "error", }, { label: `DPH (${MONTH_NAMES[statsMonth - 1]})`, value: vat.value, footer: vat.detail || "z vydaných faktur", color: "info", }, ]; return ( {cards.map((c) => ( ))} ); }; // Per-row overdue tint (replaces offers-expired-row). // Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological) // to avoid the UTC-vs-local off-by-one near midnight Prague. const rowSx = (inv: Invoice) => { const isOverdue = inv.status === "overdue" || (inv.status === "issued" && !!inv.due_date && normalizeDateStr(inv.due_date) < todayLocalStr()); if (isOverdue) { return { backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)", "&:hover": { backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.18)", }, }; } return {}; }; const columns: DataColumn[] = [ { key: "invoice_number", header: "Číslo", width: "15%", sortKey: "invoice_number", mono: true, render: (inv) => ( {inv.invoice_number} ), }, { key: "customer_name", header: "Zákazník", width: "22%", render: (inv) => inv.customer_name || "—", }, { key: "status", header: "Stav", width: "13%", sortKey: "status", render: (inv) => inv.status === "paid" ? ( ) : ( toggleStatus(inv)} /> ), }, { key: "issue_date", header: "Vystaveno", width: "12%", sortKey: "issue_date", mono: true, render: (inv) => formatDate(inv.issue_date), }, { key: "due_date", header: "Splatnost", width: "12%", sortKey: "due_date", mono: true, render: (inv) => ( {formatDate(inv.due_date)} ), }, { key: "total", header: "Celkem", width: "12%", align: "right", mono: true, bold: true, render: (inv) => formatCurrency(inv.total, inv.currency), }, { key: "actions", header: "Akce", width: "14%", align: "right", render: (inv) => ( {inv.status === "paid" ? ViewIcon : EditIcon} {hasPermission("invoices.export") && ( handlePdf(inv)} title="Zobrazit fakturu" aria-label="Zobrazit fakturu" disabled={pdfLoading === inv.id} > {pdfLoading === inv.id ? : PdfIcon} )} {hasPermission("invoices.delete") && ( setDeleteConfirm({ show: true, invoice: inv })} title="Smazat" aria-label="Smazat" > {DeleteIcon} )} ), }, ]; return ( setReceivedUploadOpen(true)} > Nahrát faktury ) : ( ) ) : undefined } /> {ChevronLeftIcon} {monthLabel} {ChevronRightIcon} setActiveTab(v)} tabs={[ { value: "issued", label: "Vydané" }, { value: "received", label: "Přijaté" }, ]} /> {activeTab === "received" ? ( }> ) : ( <> {renderKpi()} { setStatusFilter(v); setPage(1); }} tabs={statusTabs} /> { setSearch(e.target.value); setPage(1); }} placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..." fullWidth /> {draft && !search && !statusFilter && ( Koncept {draft.savedAt && ( ·{" "} {new Date(draft.savedAt).toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit", })} )} {(draft.form.customer_name as string) || "—"} ·{" "} {draft.form.issue_date ? formatDate(draft.form.issue_date as string) : "—"} {" — "} {draft.form.due_date ? formatDate(draft.form.due_date as string) : "—"} )} columns={columns} rows={invoices} rowKey={(inv) => inv.id} rowSx={rowSx} sortBy={sort} sortDir={order} onSort={handleSort} empty={ } /> 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" confirmVariant="danger" loading={deleting} /> )} ); }