From 5ca03bd662d54ded5c3ee314af555605b42152ad Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 01:09:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(mui):=20migrate=20Invoices=20(Faktury=20?= =?UTF-8?q?=E2=80=94=20Vydan=C3=A9=20+=20P=C5=99ijat=C3=A9)=20onto=20MUI?= =?UTF-8?q?=20kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/pages/Invoices.tsx | 1226 +++++++++++------------ src/admin/pages/ReceivedInvoices.tsx | 1354 ++++++++++++-------------- 2 files changed, 1202 insertions(+), 1378 deletions(-) diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index 266cb81..c7ce09c 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -2,24 +2,43 @@ 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, useSearchParams } from "react-router-dom"; -import { motion, AnimatePresence } from "framer-motion"; -import ConfirmModal from "../components/ConfirmModal"; +import { Link as RouterLink, useSearchParams } from "react-router-dom"; +import { motion } from "framer-motion"; +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 } from "../utils/formatters"; -import SortIcon from "../components/SortIcon"; -import useTableSort from "../hooks/useTableSort"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; +import useTableSort from "../hooks/useTableSort"; import { invoiceListOptions, invoiceStatsOptions, type Invoice, - type InvoiceStats, type CurrencyAmount, } from "../lib/queries/invoices"; -import Pagination from "../components/Pagination"; +import { + Button, + Card, + DataTable, + Pagination, + ConfirmDialog, + TextField, + StatusChip, + StatCard, + Alert, + EmptyState, + LoadingState, + FilterBar, + Tabs, + PageHeader, + type DataColumn, + type TabDef, + type StatCardColor, +} from "../ui"; const ReceivedInvoices = lazy(() => import("./ReceivedInvoices")); const API_BASE = "/api/admin"; @@ -67,10 +86,13 @@ const STATUS_LABELS: Record = { overdue: "Po splatnosti", }; -const STATUS_CLASSES: Record = { - issued: "admin-badge-invoice-issued", - paid: "admin-badge-invoice-paid", - overdue: "admin-badge-invoice-overdue", +const STATUS_COLORS: Record< + string, + "default" | "success" | "warning" | "error" +> = { + issued: "warning", + paid: "success", + overdue: "error", }; const STATUS_FILTERS = [ @@ -86,6 +108,112 @@ interface DraftData { 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(); @@ -96,8 +224,7 @@ export default function Invoices() { const setActiveTab = (tab: string) => setSearchParams({ tab }, { replace: true }); const [receivedUploadOpen, setReceivedUploadOpen] = useState(false); - const { sort, order, handleSort, activeSort } = - useTableSort("invoice_number"); + const { sort, order, handleSort } = useTableSort("invoice_number"); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); @@ -105,10 +232,7 @@ export default function Invoices() { const now = new Date(); const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1); const [statsYear, setStatsYear] = useState(now.getFullYear()); - const hasLoadedOnce = useRef(false); - const slideDirection = useRef(0); const blobUrlRef = useRef(null); - const [slideKey, setSlideKey] = useState(0); useEffect(() => { return () => { @@ -126,15 +250,7 @@ export default function Invoices() { const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear)); const stats = statsQuery.data ?? null; - useEffect(() => { - if (statsQuery.data) { - hasLoadedOnce.current = true; - setSlideKey((k) => k + 1); - } - }, [statsQuery.data]); - const prevMonth = () => { - slideDirection.current = -1; if (statsMonth === 1) { setStatsMonth(12); setStatsYear((y) => y - 1); @@ -145,7 +261,6 @@ export default function Invoices() { const nextMonth = () => { if (isCurrentMonth) return; - slideDirection.current = 1; if (statsMonth === 12) { setStatsMonth(1); setStatsYear((y) => y + 1); @@ -279,72 +394,286 @@ export default function Invoices() { }; if (initialLoad) { - return ( -
-
-
- ); + 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). + const rowSx = (inv: Invoice) => { + const isOverdue = + inv.status === "overdue" || + (inv.status === "issued" && + inv.due_date && + new Date(inv.due_date) < new Date(new Date().toDateString())); + if (isOverdue) { + return { + bgcolor: "warning.light", + opacity: 0.85, + "&:hover": { + bgcolor: "warning.light", + filter: "brightness(0.97)", + }, + }; + } + 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 ( -
+ -
-

Faktury

-

- {pagination?.total ?? invoices.length}{" "} - {czechPlural( - pagination?.total ?? invoices.length, - "faktura", - "faktury", - "faktur", - )} -

-
- {hasPermission("invoices.create") && ( -
- {activeTab === "received" ? ( - - ) : ( - - + ) : ( + - Nová faktura - - )} -
- )} + Nová faktura + + ) + ) : undefined + } + />
-
- - {monthLabel} - -
+ {ChevronRightIcon} + +
-
- - -
+ + setActiveTab(v)} + tabs={[ + { value: "issued", label: "Vydané" }, + { value: "received", label: "Přijaté" }, + ]} + /> + {activeTab === "received" ? ( @@ -411,13 +730,7 @@ export default function Invoices() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.1 }} > - -
-
- } - > + }> - {statsQuery.isPending && !hasLoadedOnce.current ? ( -
-
-
- ) : ( - 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"} -
-
- - ); - })()} -
-
-
- ) - )} + {renderKpi()} -
- {STATUS_FILTERS.map((f) => ( - - ))} -
+ + { + 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) + : "—"} + + + + + + + + + + )} + -
-
- { - 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{" "} - - CelkemAkce
- - 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)} - -
- - {inv.status === "paid" ? ( - - - - - ) : ( - - - - - )} - - {hasPermission("invoices.export") && ( - - )} - {hasPermission("invoices.delete") && ( - - )} -
-
-
- )} -
-
- -
+ + + columns={columns} + rows={invoices} + rowKey={(inv) => inv.id} + rowSx={rowSx} + sortBy={sort} + sortDir={order} + onSort={handleSort} + empty={ + + } + /> + +
- setDeleteConfirm({ show: false, invoice: null })} onConfirm={handleDelete} @@ -984,11 +888,11 @@ export default function Invoices() { message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" - type="danger" + confirmVariant="danger" loading={deleting} /> )} -
+ ); } diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index 803f939..2ce03cc 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -1,16 +1,16 @@ 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 { motion, AnimatePresence } from "framer-motion"; -import ConfirmModal from "../components/ConfirmModal"; -import FormModal from "../components/FormModal"; -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 AdminDatePicker from "../components/AdminDatePicker"; import { companySettingsOptions, type CompanySettingsData, @@ -20,10 +20,26 @@ import { receivedInvoiceListOptions, receivedInvoiceStatsOptions, type ReceivedInvoice, - type ReceivedStats, 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"; @@ -31,9 +47,9 @@ const STATUS_LABELS: Record = { unpaid: "Neuhrazena", paid: "Uhrazena", }; -const STATUS_CLASSES: Record = { - unpaid: "admin-badge-invoice-overdue", - paid: "admin-badge-invoice-paid", +const STATUS_COLORS: Record = { + unpaid: "warning", + paid: "success", }; const DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"]; const DEFAULT_VAT_RATES = [0, 10, 12, 15, 21]; @@ -96,6 +112,86 @@ interface ReceivedInvoicesProps { 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č"; @@ -144,7 +240,7 @@ export default function ReceivedInvoices({ const alert = useAlert(); const { hasPermission } = useAuth(); const queryClient = useQueryClient(); - const { sort, order, handleSort, activeSort } = useTableSort("created_at"); + const { sort, order, handleSort } = useTableSort("created_at"); const [search, setSearch] = useState(""); const [editOpen, setEditOpen] = useState(false); @@ -156,11 +252,7 @@ export default function ReceivedInvoices({ invoice: ReceivedInvoice | null; }>({ show: false, invoice: null }); const hasLoadedOnce = useRef(false); - const slideDirection = useRef(0); const blobTimeoutsRef = useRef[]>([]); - const [slideKey, setSlideKey] = useState(0); - const prevMonth = useRef(statsMonth); - const prevYear = useRef(statsYear); const { data: supplierNames = [] } = useQuery(supplierListOptions()); const companySettings = useQuery(companySettingsOptions()).data; @@ -221,13 +313,6 @@ export default function ReceivedInvoices({ }, }); - // Trigger slide animation when stats data changes - useEffect(() => { - if (statsQuery.data) { - setSlideKey((k) => k + 1); - } - }, [statsQuery.data]); - const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current; const [uploadFiles, setUploadFiles] = useState([]); @@ -235,22 +320,12 @@ export default function ReceivedInvoices({ const [uploadErrors, setUploadErrors] = useState({}); const fileInputRef = useRef(null); - // FormModal handles useModalLock internally useEffect(() => { return () => { blobTimeoutsRef.current.forEach(clearTimeout); }; }, []); - // Compute slide direction during render (not in effect) so it's - // available for the current frame instead of one render late. - const prevTotal = prevYear.current * 12 + prevMonth.current; - const currTotal = statsYear * 12 + statsMonth; - if (currTotal > prevTotal) slideDirection.current = 1; - else if (currTotal < prevTotal) slideDirection.current = -1; - prevMonth.current = statsMonth; - prevYear.current = statsYear; - const currencyOptions = companySettings?.available_currencies || DEFAULT_CURRENCIES; const vatRateOptions = @@ -475,13 +550,135 @@ export default function ReceivedInvoices({ 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 ( -
-
-
- ); + return ; } if (!stats) { return null; @@ -491,75 +688,67 @@ export default function ReceivedInvoices({ 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 ( -
- - ({ - 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`} -
-
-
-
-
+ + {cards.map((c) => ( + + ))} + ); }; @@ -568,266 +757,58 @@ export default function ReceivedInvoices({ {renderKpi()} - - -
-
- + + setSearch(e.target.value)} - className="admin-form-input" placeholder="Hledat podle dodavatele nebo čísla faktury..." + fullWidth /> -
+ - {showListSkeleton && ( -
-
-
+ {showListSkeleton ? ( + + ) : ( + + columns={columns} + rows={invoices} + rowKey={(inv) => inv.id} + sortBy={sort} + sortDir={order} + onSort={handleSort} + empty={ + + } + /> )} - {!showListSkeleton && invoices.length === 0 && ( -
-
- - - - - - -
-

Žádné přijaté faktury v tomto měsíci.

- {hasPermission("invoices.create") && ( -

- Nahrajte faktury tlačítkem výše. -

- )} -
- )} - {!showListSkeleton && 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 */} - setUploadOpen(false)} onSubmit={handleUploadSave} title="Nahrát přijaté faktury" - submitLabel="Uložit vše" - cancelLabel="Zrušit" + submitText="Uložit vše" + cancelText="Zrušit" loading={saving} - size="lg" - hideFooter={saving} + submitDisabled={uploadFiles.length === 0} + maxWidth="lg" > -
+ - - + PDF, JPEG, PNG · max 10 MB · max 20 souborů - -
+ + {uploadFiles.length === 0 && ( -
-

- Zatím nebyly vybrány žádné soubory. -

-
+ )} -
+ {uploadFiles.map((file, idx) => ( -
-
-
- + + + {FileIcon} + - - - - {file.name} - + {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, "supplier_name", value) + } + renderInput={(params) => ( + + )} + /> + + + + updateMeta(idx, "invoice_number", e.target.value) + } + /> + + + + - updateMeta(idx, "amount", e.target.value) } + error={!!uploadErrors[idx]?.amount} /> - - - - updateMeta(idx, "currency", e.target.value) - } - > - {currencyOptions.map((c) => ( - - ))} - - - - - updateMeta(idx, "vat_rate", e.target.value) - } - > - {vatRateOptions.map((r) => ( - - ))} - - -
- {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, "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) - } + onChange={(val) => updateMeta(idx, "issue_date", val)} /> - - - + + + + - updateMeta(idx, "due_date", val) - } + onChange={(val) => updateMeta(idx, "due_date", val)} /> - -
- - updateMeta(idx, "notes", e.target.value)} - /> - -
-
+ +
+ + + updateMeta(idx, "notes", e.target.value)} + /> + + ))} -
+ {saving && ( -
Nahrávání... -
+ )} {uploadFiles.length === 0 && ( -
Vyberte alespoň jeden soubor pro uložení. -
+ )} -
+ - {/* Edit Modal */} - setEditOpen(false)} onSubmit={ editInvoice && editInvoice._originalStatus !== "paid" ? handleEditSave - : undefined + : () => setEditOpen(false) } title={ editInvoice && editInvoice._originalStatus === "paid" ? "Detail přijaté faktury" : "Upravit přijatou fakturu" } - submitLabel="Uložit" - cancelLabel={ + submitText={ + editInvoice && editInvoice._originalStatus === "paid" + ? "Zavřít" + : "Uložit" + } + cancelText={ editInvoice && editInvoice._originalStatus === "paid" ? "Zavřít" : "Zrušit" } loading={saving} - hideFooter > {editInvoice && (() => { const ro = editInvoice._originalStatus === "paid"; return ( <> -
- - - 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, 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, ) } - readOnly={ro} /> - - - + disabled={ro} + onChange={(value) => setEditInvoice((prev) => - prev ? { ...prev, currency: e.target.value } : null, + prev ? { ...prev, currency: value } : null, ) } - disabled={ro} - > - {currencyOptions.map((c) => ( - - ))} - - - - + disabled={ro} + onChange={(value) => setEditInvoice((prev) => - prev ? { ...prev, vat_rate: e.target.value } : null, + prev ? { ...prev, vat_rate: value } : null, ) } - disabled={ro} - > - {vatRateOptions.map((r) => ( - - ))} - - -
- {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, - )} -
- )} -
- - ({ + value: String(r), + label: `${r}%`, + }))} + /> + + + + {editInvoice.amount && ( + + DPH:{" "} + {formatCurrency( + vatOf(editInvoice.amount, editInvoice.vat_rate), + editInvoice.currency || defaultCurrency, + )} + + )} + + + + + disabled={ro} + onChange={(val) => setEditInvoice((prev) => prev ? { ...prev, issue_date: val } : null, ) } - disabled={ro} /> - - - + + + + + disabled={ro} + onChange={(val) => setEditInvoice((prev) => prev ? { ...prev, due_date: val } : null, ) } - disabled={ro} /> - -
- - - - -