import { useState, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { motion } from "framer-motion"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Autocomplete from "@mui/material/Autocomplete"; import MuiTextField from "@mui/material/TextField"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import useTableSort from "../hooks/useTableSort"; import { companySettingsOptions, type CompanySettingsData, } from "../lib/queries/settings"; import { supplierListOptions } from "../lib/queries/common"; import { receivedInvoiceListOptions, receivedInvoiceStatsOptions, type ReceivedInvoice, type CurrencyAmount, } from "../lib/queries/invoices"; import { useApiMutation } from "../lib/queries/mutations"; import { Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, StatCard, EmptyState, LoadingState, type DataColumn, type StatCardColor, } from "../ui"; const API_BASE = "/api/admin"; const STATUS_LABELS: Record = { unpaid: "Neuhrazena", paid: "Uhrazena", }; const STATUS_COLORS: Record = { unpaid: "warning", paid: "success", }; 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 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 EditInvoicePayload { 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; } interface UploadErrors { [idx: number]: { [field: string]: string; }; } interface ReceivedInvoicesProps { statsMonth: number; statsYear: number; uploadOpen: boolean; setUploadOpen: (open: boolean) => void; } const ViewIcon = ( ); const EditIcon = ( ); const DeleteIcon = ( ); const RemoveIcon = ( ); const FileIcon = ( ); const UploadIcon = ( ); 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 }; } function emptyMeta( settings: CompanySettingsData | null | undefined, ): 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 queryClient = useQueryClient(); const { sort, order, handleSort } = useTableSort("created_at"); const [search, setSearch] = useState(""); const [editOpen, setEditOpen] = useState(false); const [editInvoice, setEditInvoice] = useState(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: ReceivedInvoice | null; }>({ show: false, invoice: null }); const hasLoadedOnce = useRef(false); const blobTimeoutsRef = useRef[]>([]); const { data: supplierNames = [] } = useQuery(supplierListOptions()); const companySettings = useQuery(companySettingsOptions()).data; // List query — auto-refetches when filters change const listQuery = useQuery( receivedInvoiceListOptions({ month: statsMonth, year: statsYear, search, sort, order, }), ); // Stats query — auto-refetches when month/year change const statsQuery = useQuery( receivedInvoiceStatsOptions(statsMonth, statsYear), ); // Derive list data from query (paginatedJsonQuery returns { data, pagination }) const invoices = listQuery.data?.data ?? []; if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true; // Derive stats from query const stats = statsQuery.data ?? null; // Edit mutation (JSON PUT — useApiMutation compatible) const editMutation = useApiMutation< EditInvoicePayload, { success: boolean; error?: string; message?: string } >({ method: "PUT", url: (payload) => `${API_BASE}/received-invoices/${payload.id}`, invalidate: ["invoices", "suppliers", "company-settings"], }); // Delete mutation const deleteMutation = useApiMutation< { id: number }, { success: boolean; error?: string; message?: string } >({ method: "DELETE", url: (payload) => `${API_BASE}/received-invoices/${payload.id}`, invalidate: ["invoices", "suppliers", "company-settings"], }); // Toggle paid status mutation const toggleStatusMutation = useApiMutation< { id: number; status: "paid" }, { success: boolean; error?: string; message?: string } >({ method: "PUT", url: ({ id }) => `${API_BASE}/received-invoices/${id}`, invalidate: ["invoices", "suppliers", "company-settings"], onSuccess: () => { alert.success("Faktura označena jako uhrazená"); }, }); const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current; const [uploadFiles, setUploadFiles] = useState([]); const [uploadMeta, setUploadMeta] = useState([]); const [uploadErrors, setUploadErrors] = useState({}); const fileInputRef = useRef(null); useEffect(() => { return () => { blobTimeoutsRef.current.forEach(clearTimeout); }; }, []); 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); 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({}); queryClient.invalidateQueries({ queryKey: ["invoices"], }); queryClient.invalidateQueries({ queryKey: ["suppliers"] }); queryClient.invalidateQueries({ queryKey: ["company-settings"] }); } 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: EditInvoicePayload = { id: editInvoice.id, 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, }; await editMutation.mutateAsync(payload); alert.success("Faktura byla aktualizována"); setEditOpen(false); setEditInvoice(null); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); } finally { setSaving(false); } }; const handleDelete = async () => { if (!deleteConfirm.invoice) { return; } setDeleting(true); try { await deleteMutation.mutateAsync({ id: deleteConfirm.invoice.id }); alert.success("Faktura byla smazána"); setDeleteConfirm({ show: false, invoice: null }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba při mazání"); } 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; const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba připojení"); } }; const toggleStatus = async (inv: ReceivedInvoice) => { if (inv.status === "paid") return; try { await toggleStatusMutation.mutateAsync({ id: inv.id, status: "paid" }); } catch (e) { alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav"); } }; const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`; // Live VAT calc for a (amount, rate) pair — preserved verbatim. const vatOf = (amount: string, rate: string) => { const a = parseFloat(amount || "0"); const r = parseFloat(rate || defaultVatRate); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; }; const columns: DataColumn[] = [ { key: "supplier_name", header: "Dodavatel", width: "20%", sortKey: "supplier_name", render: (inv) => inv.supplier_name, }, { key: "invoice_number", header: "Č. faktury", width: "14%", sortKey: "invoice_number", mono: true, render: (inv) => inv.invoice_number ? ( openFile(inv)} sx={{ color: "primary.main", cursor: "pointer", "&:hover": { textDecoration: "underline" }, }} > {inv.invoice_number} ) : ( "—" ), }, { key: "status", header: "Stav", width: "12%", 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: "amount", header: "Částka", width: "14%", align: "right", sortKey: "amount", mono: true, bold: true, render: (inv) => formatCurrency(inv.amount, inv.currency), }, { key: "actions", header: "Akce", width: "14%", align: "right", render: (inv) => ( {inv.file_name && ( openFile(inv)} > {ViewIcon} )} {hasPermission("invoices.edit") && ( openEdit(inv)} > {EditIcon} )} {hasPermission("invoices.delete") && ( setDeleteConfirm({ show: true, invoice: inv })} > {DeleteIcon} )} ), }, ]; const renderKpi = () => { if (statsQuery.isPending && !hasLoadedOnce.current) { return ; } 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); const cards: { label: string; value: string; footer: string; color: StatCardColor; }[] = [ { label: `Celkem (${monthLabel})`, value: total.value, footer: total.detail || `${stats.month_count} ${czechPlural(stats.month_count, "faktura", "faktury", "faktur")}`, color: "success", }, { label: `DPH k odpočtu (${monthLabel})`, value: vat.value, footer: vat.detail || "z přijatých faktur", color: "info", }, { label: "Neuhrazeno · celkově", value: unpaid.value, footer: unpaid.detail || (stats.unpaid_count === 0 ? "vše uhrazeno" : `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, "faktura", "faktury", "faktur")}`), color: "warning", }, { label: `Počet (${monthLabel})`, value: String(stats.month_count), footer: stats.month_count === 0 ? "žádné faktury" : "přijatých faktur", color: "default", }, ]; return ( {cards.map((c) => ( ))} ); }; return ( <> {renderKpi()} setSearch(e.target.value)} placeholder="Hledat podle dodavatele nebo čísla faktury..." fullWidth /> {showListSkeleton ? ( ) : ( columns={columns} rows={invoices} rowKey={(inv) => inv.id} sortBy={sort} sortDir={order} onSort={handleSort} empty={ } /> )} {/* Upload Modal */} setUploadOpen(false)} onSubmit={handleUploadSave} title="Nahrát přijaté faktury" submitText="Uložit vše" cancelText="Zrušit" loading={saving} submitDisabled={uploadFiles.length === 0} maxWidth="lg" > PDF, JPEG, PNG · max 10 MB · max 20 souborů {uploadFiles.length === 0 && ( )} {uploadFiles.map((file, idx) => ( {FileIcon} {file.name} {Math.round(file.size / 1024)} KB removeUploadFile(idx)} > {RemoveIcon} updateMeta(idx, "supplier_name", value) } renderInput={(params) => ( )} /> updateMeta(idx, "invoice_number", e.target.value) } /> updateMeta(idx, "amount", e.target.value) } error={!!uploadErrors[idx]?.amount} /> updateMeta(idx, "vat_rate", value)} options={vatRateOptions.map((r) => ({ value: String(r), label: `${r}%`, }))} /> {uploadMeta[idx]?.amount && ( DPH:{" "} {formatCurrency( vatOf(uploadMeta[idx].amount, uploadMeta[idx].vat_rate), uploadMeta[idx].currency || defaultCurrency, )} )} updateMeta(idx, "issue_date", val)} /> updateMeta(idx, "due_date", val)} /> updateMeta(idx, "notes", e.target.value)} /> ))} {saving && ( Nahrávání... )} {uploadFiles.length === 0 && ( Vyberte alespoň jeden soubor pro uložení. )} {/* Edit Modal — read-only variant when the invoice was already paid */} setEditOpen(false)} onSubmit={ editInvoice && editInvoice._originalStatus !== "paid" ? handleEditSave : () => setEditOpen(false) } title={ editInvoice && editInvoice._originalStatus === "paid" ? "Detail přijaté faktury" : "Upravit přijatou fakturu" } submitText={ editInvoice && editInvoice._originalStatus === "paid" ? "Zavřít" : "Uložit" } cancelText={ editInvoice && editInvoice._originalStatus === "paid" ? "Zavřít" : "Zrušit" } loading={saving} > {editInvoice && (() => { const ro = editInvoice._originalStatus === "paid"; return ( <> setEditInvoice((prev) => prev ? { ...prev, supplier_name: value } : null, ) } renderInput={(params) => ( )} /> setEditInvoice((prev) => prev ? { ...prev, invoice_number: e.target.value } : null, ) } slotProps={{ input: { readOnly: ro } }} /> setEditInvoice((prev) => prev ? { ...prev, amount: e.target.value } : null, ) } /> setEditInvoice((prev) => prev ? { ...prev, vat_rate: value } : null, ) } options={vatRateOptions.map((r) => ({ value: String(r), label: `${r}%`, }))} /> {editInvoice.amount && ( DPH:{" "} {formatCurrency( vatOf(editInvoice.amount, editInvoice.vat_rate), editInvoice.currency || defaultCurrency, )} )} setEditInvoice((prev) => prev ? { ...prev, issue_date: val } : null, ) } /> setEditInvoice((prev) => prev ? { ...prev, due_date: val } : null, ) } />