import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useNavigate, useSearchParams, useParams, Link, } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import DOMPurify from "dompurify"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { Skeleton } from "boneyard-js/react"; import InvoiceDetailFixture from "../fixtures/InvoiceDetailFixture"; import FormField from "../components/FormField"; import AdminDatePicker from "../components/AdminDatePicker"; import ConfirmModal from "../components/ConfirmModal"; import { motion } from "framer-motion"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { restrictToVerticalAxis, restrictToParentElement, } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import apiFetch from "../utils/api"; import { companySettingsOptions } from "../lib/queries/settings"; import { invoiceDetailOptions } from "../lib/queries/invoices"; import { offerCustomersOptions } from "../lib/queries/offers"; import { bankAccountsOptions } from "../lib/queries/common"; import { jsonQuery } from "../lib/apiAdapter"; import { formatCurrency, formatDate } from "../utils/formatters"; const API_BASE = "/api/admin"; 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 TRANSITION_LABELS: Record = { paid: "Zaplaceno" }; const TRANSITION_CLASSES: Record = { paid: "admin-btn admin-btn-primary", }; interface InvoiceItem { id?: number; _key: string; description: string; quantity: number; unit: string; unit_price: number; vat_rate: number; } interface Customer { id: number; name: string; company_id?: string; city?: string; } interface BankAccount { id: number; account_name: string; account_number?: string; bank_name?: string; bic?: string; iban?: string; is_default?: boolean; } interface InvoiceForm { customer_id: number | null; customer_name: string; order_id: number | null; issue_date: string; due_date: string; tax_date: string; currency: string; apply_vat: number; vat_rate: number; payment_method: string; constant_symbol: string; issued_by: string; billing_text: string; notes: string; language: string; bank_account_id: number | string; bank_name: string; bank_swift: string; bank_iban: string; bank_account: string; } interface InvoiceCustomer { company_id?: string; vat_id?: string; } interface Invoice { id: number; invoice_number: string; customer_id?: number | null; customer_name: string | null; customer?: InvoiceCustomer; order_id?: number; order_number?: string; currency: string; status: string; issue_date: string; due_date: string; tax_date: string; payment_method: string; constant_symbol?: string; issued_by: string | null; paid_date?: string; notes: string; language: string; apply_vat: number | string; vat_rate?: number; billing_text?: string; bank_name?: string; bank_swift?: string; bank_iban?: string; bank_account?: string; bank_account_id?: number | null; items: Omit[]; valid_transitions?: string[]; } // Sortable row for create mode function SortableInvoiceRow({ item, index, currency, apply_vat, vatOptions, onUpdate, onRemove, canDelete, }: { item: InvoiceItem; index: number; currency: string; apply_vat: boolean; vatOptions: { value: number; label: string }[]; onUpdate: ( index: number, field: keyof InvoiceItem, value: string | number, ) => void; onRemove: (index: number) => void; canDelete: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item._key }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--bg-secondary)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); return ( {index + 1} onUpdate(index, "description", e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> onUpdate(index, "quantity", e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: "center", height: "2.25rem", padding: "0.375rem 0.5rem", }} /> onUpdate(index, "unit", e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: "center", height: "2.25rem", padding: "0.375rem 0.5rem", }} /> onUpdate(index, "unit_price", e.target.value)} className="admin-form-input" step="any" style={{ textAlign: "right", height: "2.25rem", padding: "0.375rem 0.5rem", }} /> {apply_vat ? ( ) : null} {formatCurrency(lineTotal, currency)} {canDelete && ( )} ); } export default function InvoiceDetail() { const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id); const keyCounterRef = useRef(1); const emptyItem = useCallback( (): InvoiceItem => ({ _key: `inv-${++keyCounterRef.current}`, description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }), [], ); const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 }, }), useSensor(KeyboardSensor), ); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const alert = useAlert(); const { hasPermission, user } = useAuth(); // ─── Create mode state ─── const rawOrderId = searchParams.get("fromOrder"); const fromOrderId = !isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null; const [form, setForm] = useState({ customer_id: null, customer_name: "", order_id: fromOrderId ? Number(fromOrderId) : null, issue_date: new Date().toISOString().split("T")[0], due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0], tax_date: new Date().toISOString().split("T")[0], currency: "CZK", apply_vat: 1, vat_rate: 21, payment_method: "Příkazem", constant_symbol: "0308", issued_by: user?.fullName || "", billing_text: "", notes: "", language: "cs", bank_account_id: "", bank_name: "", bank_swift: "", bank_iban: "", bank_account: "", }); const [dueDays, setDueDays] = useState(14); const [items, setItems] = useState([ { _key: "inv-1", description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }, ]); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); const [dataReady, setDataReady] = useState(false); const [invoiceNumber, setInvoiceNumber] = useState(""); const initialSnapshotRef = useRef(null); const [customerSearch, setCustomerSearch] = useState(""); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const companySettings = useQuery(companySettingsOptions()).data as unknown as | { default_currency: string; default_vat_rate: number; available_currencies: string[]; available_vat_rates: number[]; } | undefined; useEffect(() => { if (companySettings && !isEdit) { setForm((prev) => ({ ...prev, currency: prev.currency === "CZK" ? companySettings.default_currency || "CZK" : prev.currency, vat_rate: prev.vat_rate === 21 ? (companySettings.default_vat_rate ?? 21) : prev.vat_rate, })); } }, [companySettings, isEdit]); const vatOptions = ( companySettings?.available_vat_rates || [0, 10, 12, 15, 21] ).map((v) => ({ value: v, label: `${v}%`, })); const DRAFT_KEY = "boha_invoice_draft"; const clearDraft = useCallback(() => { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } }, []); // ─── TanStack Query ─── const queryClient = useQueryClient(); const customersQuery = useQuery(offerCustomersOptions()); const customers = useMemo(() => { const data = customersQuery.data; if (!data) return []; if (Array.isArray(data)) return data as Customer[]; const obj = data as Record; if (Array.isArray(obj.customers)) return obj.customers as Customer[]; return []; }, [customersQuery.data]); const bankAccountsQuery = useQuery(bankAccountsOptions()); const bankAccounts = (bankAccountsQuery.data ?? []) as BankAccount[]; const invoiceQuery = useQuery(invoiceDetailOptions(id)); const invoice = (invoiceQuery.data as Invoice | undefined) ?? null; const nextNumberQuery = useQuery({ queryKey: ["invoices", "next-number"], queryFn: () => jsonQuery<{ next_number?: string; number?: string }>( `${API_BASE}/invoices/next-number`, ).then((d) => d?.next_number || d?.number || ""), enabled: !isEdit, }); const orderDataQuery = useQuery({ queryKey: ["invoices", "order-data", fromOrderId], queryFn: () => jsonQuery>( `${API_BASE}/invoices/order-data/${fromOrderId}`, ), enabled: !!fromOrderId, }); // ─── Edit mode state ─── const [notes, setNotes] = useState(""); const [statusChanging, setStatusChanging] = useState(null); const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null; }>({ show: false, status: null }); const [pdfLoading, setPdfLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const blobTimeoutsRef = useRef[]>([]); const [deleting, setDeleting] = useState(false); useEffect(() => { return () => { blobTimeoutsRef.current.forEach(clearTimeout); }; }, []); // ─── Sync query data to form state ─── // Edit mode: populate form from invoice data useEffect(() => { if (!isEdit || dataReady) return; if ( invoiceQuery.isLoading || bankAccountsQuery.isLoading || customersQuery.isLoading ) return; if (!invoiceQuery.data) return; const inv = invoiceQuery.data as Record; // Match bank account from invoice's bank details let matchedBankId: number | string = ""; const bankData = bankAccountsQuery.data ?? []; if (Array.isArray(bankData)) { const match = bankData.find( (b: BankAccount) => (inv.bank_iban && b.iban === inv.bank_iban) || (inv.bank_account && b.account_number === inv.bank_account), ); if (match) matchedBankId = match.id; } const formData = { customer_id: (inv.customer_id as number) || null, customer_name: (inv.customer_name as string) || "", order_id: (inv.order_id as number) || null, issue_date: inv.issue_date ? new Date(inv.issue_date as string).toISOString().split("T")[0] : "", due_date: inv.due_date ? new Date(inv.due_date as string).toISOString().split("T")[0] : "", tax_date: inv.tax_date ? new Date(inv.tax_date as string).toISOString().split("T")[0] : "", currency: (inv.currency as string) || "CZK", apply_vat: Number(inv.apply_vat) ? 1 : 0, vat_rate: Number(inv.vat_rate) || 21, payment_method: (inv.payment_method as string) || "Příkazem", constant_symbol: (inv.constant_symbol as string) || "0308", issued_by: (inv.issued_by as string) || "", billing_text: (inv.billing_text as string) || "", notes: (inv.notes as string) || "", language: (inv.language as string) || "cs", bank_account_id: matchedBankId, bank_name: (inv.bank_name as string) || "", bank_swift: (inv.bank_swift as string) || "", bank_iban: (inv.bank_iban as string) || "", bank_account: (inv.bank_account as string) || "", }; setForm(formData); setNotes((inv.notes as string) || ""); setInvoiceNumber((inv.invoice_number as string) || ""); // Calculate dueDays from existing dates if (inv.issue_date && inv.due_date) { const issue = new Date(inv.issue_date as string); const due = new Date(inv.due_date as string); const diffDays = Math.round( (due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24), ); if (diffDays > 0 && diffDays <= 60) setDueDays(diffDays); } // Populate items from existing invoice const invItems = inv.items as Record[] | undefined; const mappedItems = invItems && invItems.length > 0 ? invItems.map((item) => ({ _key: `inv-${++keyCounterRef.current}`, id: item.id as number | undefined, description: (item.description as string) || "", quantity: Number(item.quantity) || 1, unit: (item.unit as string) || "", unit_price: Number(item.unit_price) || 0, vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21, })) : []; if (mappedItems.length > 0) { setItems(mappedItems); } // Capture initial snapshot for dirty-checking initialSnapshotRef.current = JSON.stringify({ form: formData, items: mappedItems, }); setDataReady(true); }, [ isEdit, dataReady, invoiceQuery.isLoading, invoiceQuery.data, bankAccountsQuery.isLoading, bankAccountsQuery.data, customersQuery.isLoading, ]); // eslint-disable-line react-hooks/exhaustive-deps // Create mode: populate form from query data useEffect(() => { if (isEdit || dataReady) return; if ( nextNumberQuery.isLoading || bankAccountsQuery.isLoading || customersQuery.isLoading ) return; if (fromOrderId && orderDataQuery.isLoading) return; // Set invoice number if (nextNumberQuery.data) { setInvoiceNumber(nextNumberQuery.data); } // Set default bank account const defaultAcc = bankAccounts.find((a: BankAccount) => a.is_default); if (defaultAcc) { setForm((prev) => ({ ...prev, bank_account_id: defaultAcc.id, bank_name: defaultAcc.bank_name || "", bank_swift: defaultAcc.bic || "", bank_iban: defaultAcc.iban || "", bank_account: defaultAcc.account_number || "", })); } // Pre-fill from order if (fromOrderId && orderDataQuery.data) { const order = orderDataQuery.data; const vatRate = Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21); setForm((prev) => ({ ...prev, customer_id: order.customer_id as number, customer_name: (order.customer_name as string) || "", order_id: order.id as number, currency: (order.currency as string) || companySettings?.default_currency || "CZK", apply_vat: Number(order.apply_vat) || 0, vat_rate: vatRate, })); const orderItems = order.items as Record[] | undefined; if (orderItems && orderItems.length > 0) { setItems( orderItems.map((item) => ({ _key: `inv-${++keyCounterRef.current}`, description: (item.description as string) || "", quantity: Number(item.quantity) || 1, unit: (item.unit as string) || "", unit_price: Number(item.unit_price) || 0, vat_rate: vatRate, })), ); } } setDataReady(true); }, [ isEdit, dataReady, nextNumberQuery.isLoading, nextNumberQuery.data, bankAccountsQuery.isLoading, bankAccountsQuery.data, customersQuery.isLoading, fromOrderId, orderDataQuery.isLoading, orderDataQuery.data, companySettings, bankAccounts, ]); // eslint-disable-line react-hooks/exhaustive-deps // Capture initial snapshot for dirty-checking once data sync completes. // Edit mode: captured inside the sync effect from raw query data. // Create mode: captured on the first render after sync effects populate the form. if (dataReady && !initialSnapshotRef.current) { initialSnapshotRef.current = JSON.stringify({ form, items }); } const isDirty = useMemo(() => { if (!initialSnapshotRef.current) return false; return JSON.stringify({ form, items }) !== initialSnapshotRef.current; }, [form, items]); useEffect(() => { if (!isDirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [isDirty]); const computedDueDate = useMemo(() => { if (!form.issue_date) return ""; const d = new Date(form.issue_date); d.setDate(d.getDate() + dueDays); return d.toISOString().split("T")[0]; }, [form.issue_date, dueDays]); // ─── Create mode: customer filtering ─── const filteredCustomers = useMemo(() => { if (!customerSearch) return customers; const q = customerSearch.toLowerCase(); return customers.filter( (c) => (c.name || "").toLowerCase().includes(q) || (c.company_id || "").includes(customerSearch) || (c.city || "").toLowerCase().includes(q), ); }, [customers, customerSearch]); useEffect(() => { const handleClickOutside = () => setShowCustomerDropdown(false); if (showCustomerDropdown) { document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); } }, [showCustomerDropdown]); const selectBankAccount = (accountId: string) => { const acc = bankAccounts.find((a) => a.id === Number(accountId)); if (acc) { setForm((prev) => ({ ...prev, bank_account_id: acc.id, bank_name: acc.bank_name || "", bank_swift: acc.bic || "", bank_iban: acc.iban || "", bank_account: acc.account_number || "", })); } else { setForm((prev) => ({ ...prev, bank_account_id: "", bank_name: "", bank_swift: "", bank_iban: "", bank_account: "", })); } }; const selectCustomer = (customer: Customer) => { setForm((prev) => ({ ...prev, customer_id: customer.id, customer_name: customer.name, })); setErrors((prev) => ({ ...prev, customer_id: "" })); setCustomerSearch(""); setShowCustomerDropdown(false); }; // ─── Create mode: items management ─── const updateItem = ( index: number, field: keyof InvoiceItem, value: string | number, ) => { setItems((prev) => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), ); }; const addItem = () => setItems((prev) => [...prev, emptyItem()]); const removeItem = (index: number) => { if (items.length <= 1) return; setItems((prev) => prev.filter((_, i) => i !== index)); }; const handleCreateDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setItems((prev) => { const oldIndex = prev.findIndex((i) => i._key === String(active.id)); const newIndex = prev.findIndex((i) => i._key === String(over.id)); if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex); }); }; // ─── Create mode: totals ─── const createTotals = useMemo(() => { let subtotal = 0; const vatByRate: Record = {}; items.forEach((item) => { const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); subtotal += lineTotal; if (form.apply_vat) { const rate = Number(item.vat_rate) || 0; if (!vatByRate[rate]) vatByRate[rate] = 0; vatByRate[rate] += (lineTotal * rate) / 100; } }); const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0); return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }; }, [items, form.apply_vat]); // ─── Create/Edit mode: submit ─── const handleCreateSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka"; if (!form.issue_date) newErrors.issue_date = "Zadejte datum"; if (!form.tax_date) newErrors.tax_date = "Zadejte datum"; if (!form.bank_account_id) newErrors.bank_account_id = "Vyberte bankovní účet"; if (items.length === 0 || items.every((i) => !i.description.trim())) { newErrors.items = "Přidejte alespoň jednu položku"; } setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; setSaving(true); try { const payload: any = { ...form, due_date: computedDueDate || form.due_date, items: items .filter((i) => i.description.trim()) .map((item, i) => ({ ...item, position: i, })), }; if (isEdit) payload.invoice_number = invoiceNumber; const url = isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`; const method = isEdit ? "PUT" : "POST"; const response = await apiFetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json(); if (result.success) { if (!isEdit) clearDraft(); const invoiceId = isEdit ? id : result.data.invoice_id; await apiFetch( `${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`, ).catch(() => {}); alert.success( result.message || (isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"), ); initialSnapshotRef.current = JSON.stringify({ form, items }); if (isEdit) { queryClient.invalidateQueries({ queryKey: ["invoices", id] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); } else { navigate(`/invoices/${result.data.invoice_id}`); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["invoices"] }); } } else { alert.error( result.error || (isEdit ? "Nepodařilo se uložit fakturu" : "Nepodařilo se vytvořit fakturu"), ); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; // ─── Edit mode: status change ─── const handleStatusChange = async () => { if (!statusConfirm.status) return; setStatusChanging(statusConfirm.status); setStatusConfirm({ show: false, status: null }); try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: statusConfirm.status }), }); const result = await response.json(); if (result.success) { alert.success(result.message || "Stav byl změněn"); queryClient.invalidateQueries({ queryKey: ["invoices", id] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); } else { alert.error(result.error || "Nepodařilo se změnit stav"); } } catch { alert.error("Chyba připojení"); } finally { setStatusChanging(null); } }; // ─── Edit mode: PDF export ─── const handleViewPdf = async (_lang = "cs") => { const newWindow = window.open("", "_blank"); setPdfLoading(true); try { const response = await apiFetch(`${API_BASE}/invoices/${id}/file`); if (!response.ok) { newWindow?.close(); alert.error("PDF soubor nenalezen — uložte fakturu pro vygenerování"); 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í"); } finally { setPdfLoading(false); } }; // ─── Edit mode: delete ─── const handleDelete = async () => { setDeleting(true); try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: "DELETE", }); const result = await response.json(); if (result.success) { alert.success(result.message || "Faktura byla smazána"); queryClient.invalidateQueries({ queryKey: ["invoices"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); navigate("/invoices"); } else { alert.error(result.error || "Nepodařilo se smazat fakturu"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); setDeleteConfirm(false); } }; // ─── Permission checks ─── if (!isEdit && !hasPermission("invoices.create")) return ; if (isEdit && !hasPermission("invoices.view")) return ; // ═══════════════════════════════════════════════════════════ // PAID INVOICE — read-only view // ═══════════════════════════════════════════════════════════ const isPaid = isEdit && invoice?.status === "paid"; if (isEdit && !invoice) return null; if (isPaid && invoice) { return ( } >
{/* Header */}

Faktura {invoice.invoice_number} {STATUS_LABELS[invoice.status] || invoice.status}

{hasPermission("invoices.export") && ( )} {hasPermission("invoices.delete") && ( )}
{/* Info */}

Informace

{invoice.customer_name || "\u2014"}
{invoice.customer && (
{invoice.customer.company_id && `IČ: ${invoice.customer.company_id}`} {invoice.customer.vat_id && ` · DIČ: ${invoice.customer.vat_id}`}
)}
{invoice.order_id ? ( {invoice.order_number} ) : ( "\u2014" )}
{invoice.currency}
{formatDate(invoice.issue_date)}
{formatDate(invoice.due_date)}
{formatDate(invoice.tax_date)}
{invoice.payment_method}
{invoice.invoice_number}
{invoice.issued_by || "\u2014"}
{invoice.paid_date && (
{formatDate(invoice.paid_date)}
)}
{/* Items (read-only) */}

Položky

{invoice.items?.length > 0 ? (
{invoice.items.map((item, index) => { const lineSubtotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); const lineVat = Number(invoice.apply_vat) ? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100 : 0; return ( ); })}
# Popis Množství Jednotka Jedn. cena %DPH Celkem
{index + 1} {item.description || "\u2014"} {item.quantity}{" "} {item.unit && ( {item.unit} )} {item.unit || "\u2014"} {formatCurrency( item.unit_price, invoice.currency, )} {Number(invoice.apply_vat) ? Number(item.vat_rate) : 0} % {formatCurrency( lineSubtotal + lineVat, invoice.currency, )}
) : (

Žádné položky.

)}
Mezisoučet: {formatCurrency(createTotals.subtotal, invoice.currency)}
{Number(invoice.apply_vat) > 0 && Object.entries(createTotals.vatByRate).map( ([rate, amount]) => (
DPH {rate}%: {formatCurrency(amount, invoice.currency)}
), )}
Celkem k úhradě: {formatCurrency(createTotals.total, invoice.currency)}
{/* Notes (read-only) */}

Veřejné poznámky na faktuře

{notes && notes.trim() && notes !== "


" ? (
) : (

Žádné poznámky.

)} {/* Delete confirm */} setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat fakturu" message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" type="danger" loading={deleting} />
); } // ═══════════════════════════════════════════════════════════ // CREATE MODE + EDIT (not paid) — shared form // ═══════════════════════════════════════════════════════════ return ( } >
{isEdit && invoice ? (

Faktura {invoice.invoice_number} {STATUS_LABELS[invoice.status] || invoice.status}

) : ( <>

Nová faktura{" "} {invoiceNumber && ( ({invoiceNumber}) )}

{fromOrderId && (

Z objednávky

)} )}
{isEdit && invoice && hasPermission("invoices.export") && ( )} {isEdit && invoice && ( <> {hasPermission("invoices.edit") && invoice.valid_transitions?.map((status) => ( ))} {hasPermission("invoices.delete") && ( )} )}
{/* Basic info */}

Základní údaje

{form.customer_id ? (
{form.customer_name}
) : (
e.stopPropagation()} > { setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} className="admin-form-input" placeholder="Hledat zákazníka (název, IČ, město)..." autoComplete="off" /> {showCustomerDropdown && (
{filteredCustomers.length === 0 ? (
Žádní zákazníci
) : ( filteredCustomers.slice(0, 10).map((c) => (
selectCustomer(c)} >
{c.name}
{(c.company_id || c.city) && (
{c.company_id && `IČ: ${c.company_id}`} {c.city && ` · ${c.city}`}
)}
)) )}
)}
)}
setForm((prev) => ({ ...prev, billing_text: e.target.value, })) } className="admin-form-input" placeholder="Fakturujeme Vám za: (ponechte prázdné pro výchozí)" />
{ setForm((prev) => ({ ...prev, issue_date: val })); setErrors((prev) => ({ ...prev, issue_date: "" })); }} /> {computedDueDate && ( Splatnost:{" "} {new Date(computedDueDate).toLocaleDateString("cs-CZ")} )} { setForm((prev) => ({ ...prev, tax_date: val })); setErrors((prev) => ({ ...prev, tax_date: "" })); }} />
{/* Items */}

Položky

{errors.items && ( {errors.items} )}
i._key)} strategy={verticalListSortingStrategy} >
{form.apply_vat ? ( ) : null} {items.map((item, index) => ( 1} /> ))}
# Popis Množství Jednotka Jedn. cena DPH Celkem
{/* Totals */}
Mezisoučet: {formatCurrency(createTotals.subtotal, form.currency)}
{form.apply_vat && Object.entries(createTotals.vatByRate).map( ([rate, amount]) => (
DPH {rate}%: {formatCurrency(amount, form.currency)}
), )}
Celkem k úhradě: {formatCurrency(createTotals.total, form.currency)}
{/* Notes */}

Veřejné poznámky na faktuře