import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useNavigate, useSearchParams, useParams, Link as RouterLink, } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import DOMPurify from "dompurify"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import { useApiMutation } from "../lib/queries/mutations"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; import SectionsEditor, { type DocumentSection, } from "../components/document/SectionsEditor"; import { DndContext, closestCenter, KeyboardSensor, MouseSensor, 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, numberOr, todayLocalStr, } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, Card, TextField, Select, DateField, Field, StatusChip, CheckboxField, ConfirmDialog, EmptyState, LoadingState, PageEnter, RichTextView, CustomerPicker, headerActionsSx, } from "../ui"; import { INVOICE_STATUS, statusLabel, statusColor, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; /** * Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time. * `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is * used. We build the Date from the local year/month/day parts (so it's a local * midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read * it back with local getters — never via `toISOString()`, which would shift a * day during the evening Prague window. See utils/formatters.todayLocalStr. */ function addDaysLocalStr(days: number, base?: string): string { let y: number, m: number, d: number; const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null; if (parts) { y = Number(parts[1]); m = Number(parts[2]) - 1; d = Number(parts[3]); } else { const now = new Date(); y = now.getFullYear(); m = now.getMonth(); d = now.getDate(); } const result = new Date(y, m, d + days); const mm = String(result.getMonth() + 1).padStart(2, "0"); const dd = String(result.getDate()).padStart(2, "0"); return `${result.getFullYear()}-${mm}-${dd}`; } const TRANSITION_LABELS: Record = { paid: "Zaplaceno" }; // The live (finalized) status for an invoice — assigning it (on create or on // finalizing a draft) makes the backend consume the official invoice number. const LIVE_STATUS = "issued"; const BackIcon = ( ); const FileIcon = ( ); const DragIcon = ( ); const RemoveIcon = ( ); interface InvoiceItem { id?: number; _key: string; description: string; item_description: string; // Held as the raw typed string while editing so the field can be cleared // (empty renders fine in a type="number" input). Coerced via Number(x) || 0 // only where used (live totals + save payload). vat_rate stays numeric: it // is edited via a Select, never a free-text number input. quantity: string | number; unit: string; unit_price: string | number; vat_rate: number; } 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; internal_notes: string; language: string; bank_account_id: number | string; bank_name: string; bank_swift: string; bank_iban: string; bank_account: 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 theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, background: isDragging ? "var(--mui-palette-action-hover)" : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); if (isMobile) { return ( {DragIcon} Položka {index + 1} {canDelete && ( onRemove(index)} title="Odebrat" aria-label="Odebrat" > {RemoveIcon} )} onUpdate(index, "description", e.target.value)} placeholder="Popis položky..." /> onUpdate(index, "item_description", e.target.value)} placeholder="Volitelný" multiline rows={2} sx={{ "& textarea": { resize: "vertical" } }} /> onUpdate(index, "quantity", e.target.value)} slotProps={{ htmlInput: { min: "0", step: "any" } }} /> onUpdate(index, "unit", e.target.value)} placeholder="ks" /> onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} /> {apply_vat && ( onUpdate(index, "vat_rate", Number(val))} sx={{ minWidth: "4.5rem" }} options={vatOptions.map((o) => ({ value: String(o.value), label: o.label, }))} /> ) : null} {formatCurrency(lineTotal, currency)} {canDelete && ( onRemove(index)} title="Odebrat" aria-label="Odebrat" > {RemoveIcon} )} ); } export default function InvoiceDetail() { const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const keyCounterRef = useRef(1); const emptyItem = useCallback( (): InvoiceItem => ({ _key: `inv-${++keyCounterRef.current}`, description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }), [], ); // MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived // pointer events and its 5px distance constraint fires before the // TouchSensor's long-press delay — the browser then claims the gesture for // scrolling (pointercancel) and the drag dies. Mouse handles desktop, // TouchSensor handles touch via long-press; the drag handles additionally // carry touch-action: none so the browser never starts a scroll from them. const dndSensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 8 }, }), 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: todayLocalStr(), due_date: addDaysLocalStr(14), tax_date: todayLocalStr(), currency: "CZK", apply_vat: 1, vat_rate: 21, payment_method: "Příkazem", constant_symbol: "0308", issued_by: user?.fullName || "", billing_text: "", internal_notes: "", language: "cs", bank_account_id: "", bank_name: "", bank_swift: "", bank_iban: "", bank_account: "", })); const [dueDays, setDueDays] = useState(14); // Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF — // shared editor with offers/issued orders). const [sections, setSections] = useState([]); const [items, setItems] = useState(() => [ { _key: "inv-1", description: "", item_description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }, ]); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); // Which save action is in flight ("draft" | LIVE_STATUS | "save"), so only the // clicked button shows its spinner — `saving` still disables all of them to // prevent a concurrent double-submit. const [savingAction, setSavingAction] = useState(null); const [dataReady, setDataReady] = useState(false); const [invoiceNumber, setInvoiceNumber] = useState(""); const initialSnapshotRef = useRef(null); const companySettings = useQuery(companySettingsOptions()).data; 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}%`, })); // ─── TanStack Query ─── const customersQuery = useQuery(offerCustomersOptions()); const customers = customersQuery.data ?? []; const bankAccountsQuery = useQuery(bankAccountsOptions()); const bankAccounts = bankAccountsQuery.data ?? []; const invoiceQuery = useQuery(invoiceDetailOptions(id)); const invoice = invoiceQuery.data ?? 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 [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); }; }, []); // After an in-place draft→issued finalize, the one-shot hydration effect below // (gated by dataReady) won't re-run, so the freshly-assigned number from the // refetched query is mirrored into the local field here (matches the // issued-order page's po_number resync). useEffect(() => { if ( isEdit && invoice?.invoice_number && invoice.invoice_number !== invoiceNumber ) { setInvoiceNumber(invoice.invoice_number); } }, [isEdit, invoice?.invoice_number, invoiceNumber]); // ─── 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; // Match bank account from invoice's bank details let matchedBankId: number | string = ""; const bankData = bankAccountsQuery.data ?? []; const match = bankData.find( (b) => (inv.bank_iban && b.iban === inv.bank_iban) || (inv.bank_account && b.account_number === inv.bank_account), ); if (match) matchedBankId = match.id; const formData: InvoiceForm = { customer_id: inv.customer_id || null, customer_name: inv.customer_name || "", order_id: inv.order_id || null, issue_date: normalizeDateStr(inv.issue_date), due_date: normalizeDateStr(inv.due_date), tax_date: normalizeDateStr(inv.tax_date), currency: inv.currency || "CZK", apply_vat: Number(inv.apply_vat) ? 1 : 0, vat_rate: numberOr(inv.vat_rate, 21), payment_method: inv.payment_method || "Příkazem", constant_symbol: inv.constant_symbol || "0308", issued_by: inv.issued_by || "", billing_text: inv.billing_text || "", internal_notes: inv.internal_notes || "", language: inv.language || "cs", bank_account_id: matchedBankId, bank_name: inv.bank_name || "", bank_swift: inv.bank_swift || "", bank_iban: inv.bank_iban || "", bank_account: inv.bank_account || "", }; setForm(formData); setInvoiceNumber(inv.invoice_number || ""); // Calculate dueDays from existing dates if (inv.issue_date && inv.due_date) { const issue = new Date(inv.issue_date); const due = new Date(inv.due_date); 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; const mappedItems = invItems && invItems.length > 0 ? invItems.map((item) => ({ _key: `inv-${++keyCounterRef.current}`, id: item.id, description: item.description || "", item_description: item.item_description || "", quantity: Number(item.quantity) || 1, unit: item.unit || "", unit_price: Number(item.unit_price) || 0, // Per-line VAT: hydrate from the ITEM's own stored rate (a real DB // column returned by the detail endpoint) — falling back to the // invoice-level rate only when the line carries none. Hydrating // from the invoice rate corrupted mixed-rate invoices on re-save. vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)), })) : []; if (mappedItems.length > 0) { setItems(mappedItems); } // Populate sections from existing invoice const mappedSections = Array.isArray(inv.sections) && inv.sections.length > 0 ? inv.sections.map((s) => ({ title: s.title || "", title_cz: s.title_cz || "", content: s.content || "", })) : []; setSections(mappedSections); // Capture initial snapshot for dirty-checking initialSnapshotRef.current = JSON.stringify({ form: formData, items: mappedItems, sections: mappedSections, }); setDataReady(true); }, [ isEdit, dataReady, invoiceQuery.isLoading, invoiceQuery.data, bankAccountsQuery.isLoading, bankAccountsQuery.data, customersQuery.isLoading, ]); // 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 from the server's next-number. if (nextNumberQuery.data) { setInvoiceNumber(nextNumberQuery.data); } // Set default bank account const defaultAcc = bankAccounts.find((a) => 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. Orders no longer carry VAT (not tax documents) — // the invoice decides its own VAT: default rate from company settings, // apply_vat on. if (fromOrderId && orderDataQuery.data) { const order = orderDataQuery.data; const vatRate = numberOr(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: 1, 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) || "", item_description: (item.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, ]); // 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, sections }); } const isDirty = useMemo(() => { if (!initialSnapshotRef.current) return false; return ( JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current ); }, [form, items, sections]); 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 ""; return addDaysLocalStr(dueDays, form.issue_date); }, [form.issue_date, dueDays]); 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 = (id: number | null) => { const customer = id != null ? customers.find((c) => c.id === id) : null; setForm((prev) => ({ ...prev, customer_id: id, customer_name: customer?.name ?? "", })); setErrors((prev) => ({ ...prev, customer_id: "" })); }; // ─── 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 saveMutation = useApiMutation< Record, { invoice_id: number } >({ url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`), method: () => (isEdit ? "PUT" : "POST"), invalidate: ["invoices", "orders"], onSuccess: (data) => { const invoiceId = isEdit ? Number(id) : data.invoice_id; // PDF binary generation — KEEP as raw apiFetch void apiFetch( `${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`, ).catch(() => {}); }, }); const statusMutation = useApiMutation<{ status: string }, unknown>({ url: () => `${API_BASE}/invoices/${id}`, method: () => "PUT", invalidate: ["invoices", "orders"], }); const invoiceDeleteMutation = useApiMutation({ url: () => `${API_BASE}/invoices/${id}`, method: () => "DELETE", invalidate: ["invoices", "orders"], }); const handleCreateSubmit = async (targetStatus?: string) => { 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); setSavingAction(targetStatus ?? "save"); try { const payload: Record = { ...form, due_date: computedDueDate || form.due_date, items: items .filter((i) => i.description.trim()) .map((item, i) => ({ ...item, // Coerce the raw typed strings back to numbers so an empty/partial // field never reaches the server as NaN (mirrors createTotals). quantity: Number(item.quantity) || 0, unit_price: Number(item.unit_price) || 0, position: i, })), sections: sections.map((s, i) => ({ title: s.title, title_cz: s.title_cz, content: s.content, position: i, })), }; // Only set status when a target is given (create-as-draft / create-as-live // / finalize). Editing a live invoice sends no status — the backend keeps // its current status. if (targetStatus) payload.status = targetStatus; // The invoice number is NOT user-editable for drafts (it is NULL until // finalized). Send it back only when editing an already-numbered invoice. if (isEdit && invoice?.status !== "draft") payload.invoice_number = invoiceNumber; const data = await saveMutation.mutateAsync(payload); alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); initialSnapshotRef.current = JSON.stringify({ form, items, sections }); if (!isEdit) { navigate(`/invoices/${data.invoice_id}`); } } catch (e) { alert.error( e instanceof Error ? e.message : isEdit ? "Nepodařilo se uložit fakturu" : "Nepodařilo se vytvořit fakturu", ); } finally { setSaving(false); setSavingAction(null); } }; // Native form submit (e.g. Enter): route to the right save path. Create → // finalize live; editing a draft → save draft (the visible primary button); // editing a live invoice → plain save (no status change). const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!isEdit) void handleCreateSubmit(LIVE_STATUS); else if (invoice?.status === "draft") void handleCreateSubmit("draft"); else void handleCreateSubmit(); }; // ─── Edit mode: status change ─── const handleStatusChange = async () => { if (!statusConfirm.status) return; const newStatus = statusConfirm.status; setStatusChanging(newStatus); setStatusConfirm({ show: false, status: null }); try { await statusMutation.mutateAsync({ status: newStatus }); alert.success("Stav byl změněn"); } catch (e) { alert.error(e instanceof Error ? e.message : "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 { await invoiceDeleteMutation.mutateAsync(undefined); alert.success("Faktura byla smazána"); navigate("/invoices"); } catch (e) { alert.error(e instanceof Error ? e.message : "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) { if (!dataReady) { return ; } return ( {/* Header */} {/* flexWrap: long document titles must drop below the Zpět button on phones instead of overflowing the viewport. */} Faktura {documentNumberLabel(invoice.invoice_number)} {hasPermission("invoices.view") && ( )} {/* A paid invoice is a settled financial record — never deletable from the UI. (Backend rejection of deleting a paid invoice is a separate hardening, out of scope here.) */} {hasPermission("invoices.delete") && invoice.status !== "paid" && ( )} {/* Info */} Informace {invoice.customer_name || "—"} {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} ) : ( "—" )} {invoice.currency} {formatDate(invoice.issue_date)} {formatDate(invoice.due_date)} {formatDate(invoice.tax_date)} {invoice.payment_method} {invoice.invoice_number} {invoice.paid_date && ( {formatDate(invoice.paid_date)} )} {/* Items (read-only) */} Položky {invoice.items && invoice.items.length > 0 ? ( isMobile ? ( {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 ( {item.description || "—"} {item.item_description && ( {item.item_description} )} Množství {item.quantity} {item.unit} Jedn. cena {formatCurrency(item.unit_price, invoice.currency)} {Number(invoice.apply_vat) ? ( %DPH {Number(item.vat_rate)}% ) : null} Celkem {formatCurrency( lineSubtotal + lineVat, invoice.currency, )} ); })} ) : ( # Popis Množství Jednotka Jedn. cena DPH Celkem {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 ( {index + 1} {item.description || "—"} {item.item_description && ( {item.item_description} )} {item.quantity}{" "} {item.unit && ( {item.unit} )} {item.unit || "—"} {formatCurrency(item.unit_price, invoice.currency)} {Number(invoice.apply_vat) ? Number(item.vat_rate) : 0} % {formatCurrency( lineSubtotal + lineVat, invoice.currency, )} ); })}
) ) : ( )} 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)}
{/* Obsah sections (read-only) — shown only when any section has content */} {(invoice.sections ?? []).some( (s) => (s.title || "").trim() || (s.title_cz || "").trim() || (s.content || "").replace(/<[^>]*>/g, "").trim(), ) && ( Obsah {(invoice.sections ?? []).map((s, idx) => ( {((invoice.language === "cs" ? s.title_cz || s.title : s.title) || "") && ( {invoice.language === "cs" ? s.title_cz || s.title : s.title} )} {(s.content || "").replace(/<[^>]*>/g, "").trim() && ( )} ))} )} {/* Internal notes (read-only, never printed) */} Interní poznámky {invoice.internal_notes && invoice.internal_notes.trim() && invoice.internal_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" confirmVariant="danger" loading={deleting} />
); } // ═══════════════════════════════════════════════════════════ // CREATE MODE + EDIT (not paid) — shared form // ═══════════════════════════════════════════════════════════ if (!dataReady) { return ; } return ( {/* flexWrap: long document titles must drop below the Zpět button on phones instead of overflowing the viewport. */} {isEdit ? "Faktura" : "Nová faktura"} {/* Edit mode: a draft has no number yet → show "Koncept". Create mode: show the previewed next-number when available. */} {(isEdit || invoiceNumber) && ( {isEdit ? documentNumberLabel(invoice?.invoice_number) : invoiceNumber} )} {isEdit && invoice && ( )} {!isEdit && fromOrderId && ( Z objednávky )} {isEdit && invoice && hasPermission("invoices.view") && ( )} {/* ── Create mode: two-button save ── */} {!isEdit && ( <> )} {/* ── Edit mode: draft → save-draft + finalize ── */} {isEdit && invoice && invoice.status === "draft" && ( <> {hasPermission("invoices.edit") && ( )} {/* A draft is deleted rather than cancelled. */} {hasPermission("invoices.delete") && ( )} )} {/* ── Edit mode: live (non-draft) doc — unchanged ── */} {isEdit && invoice && invoice.status !== "draft" && ( <> {hasPermission("invoices.edit") && invoice.valid_transitions?.map((status) => ( ))} {/* A paid invoice is a settled financial record — never deletable from the UI. A paid invoice never actually reaches this branch (it renders the read-only view above), but the guard documents the invariant explicitly. */} {hasPermission("invoices.delete") && invoice.status !== "paid" && ( )} )}
{/* Basic info */} Základní údaje {/* Číslo faktury and Vystavil are not form fields anymore: the number lives in the page header (deferred numbering — "Koncept" until finalize) and issued_by is auto-filled from the logged-in user and still submitted with the payload (the PDF prints it). */} setForm((prev) => ({ ...prev, billing_text: e.target.value, })) } placeholder="Fakturujeme Vám za: (ponechte prázdné pro výchozí)" /> { setForm((prev) => ({ ...prev, issue_date: val })); setErrors((prev) => ({ ...prev, issue_date: "" })); }} /> setForm((prev) => ({ ...prev, payment_method: val })) } options={[ { value: "Příkazem", label: "Příkazem" }, { value: "Hotově", label: "Hotově" }, { value: "Dobírka", label: "Dobírka" }, ]} /> setForm((prev) => ({ ...prev, language: val })) } options={[ { value: "cs", label: "Čeština" }, { value: "en", label: "English" }, ]} /> {/* Document-default VAT rate. Presentational only — it writes the already-existing `form.vat_rate` (used to seed new line rows / as the per-line fallback). Totals are computed per-line from `item.vat_rate`, so changing this does NOT alter computed VAT. */} { selectBankAccount(val); setErrors((prev) => ({ ...prev, bank_account_id: "" })); }} options={[ { value: "", label: `— Vyberte účet —`, }, ...bankAccounts.map((acc) => ({ value: String(acc.id), label: `${acc.account_name}${ acc.account_number ? ` (${acc.account_number})` : "" }${acc.is_default ? " ★" : ""}`, })), ]} /> {/* Items */} Položky {errors.items && ( {errors.items} )} i._key)} strategy={verticalListSortingStrategy} > {isMobile ? ( {items.map((item, index) => ( 1} /> ))} ) : ( # Popis Množství Jednotka Jedn. cena {form.apply_vat ? ( DPH ) : null} Celkem {items.map((item, index) => ( 1} /> ))}
)}
{/* 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)}
{/* Rich-text PDF sections — printed after the items (like orders) */} {/* Internal notes */} setForm((prev) => ({ ...prev, internal_notes: val })) } placeholder="Interní poznámky..." /> {/* Status change confirm (edit mode only) */} {isEdit && invoice && ( <> setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav faktury" message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${statusLabel(INVOICE_STATUS, statusConfirm.status)}"?`} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } cancelText="Zrušit" /> setDeleteConfirm(false)} onConfirm={handleDelete} title="Smazat fakturu" message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`} confirmText="Smazat" cancelText="Zrušit" confirmVariant="danger" loading={deleting} /> )}
); }