import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useNavigate, useSearchParams, useParams, Link, } from "react-router-dom"; import DOMPurify from "dompurify"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; 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 { 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", }; const VAT_OPTIONS = [ { value: 21, label: "21%" }, { value: 12, label: "12%" }, { value: 0, label: "0%" }, ]; 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_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; issued_by: string | null; paid_date?: string; notes: string; language: string; apply_vat: number | string; items: Omit[]; valid_transitions?: string[]; } // Sortable row for create mode function SortableInvoiceRow({ item, index, currency, apply_vat, onUpdate, onRemove, canDelete, }: { item: InvoiceItem; index: number; currency: string; apply_vat: boolean; 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 && ( )} ); } // Sortable row for edit mode (existing invoice items) function SortableInvoiceEditRow({ item, index, apply_vat, onUpdate, onRemove, canDelete, }: { item: InvoiceItem; index: number; apply_vat: boolean; onUpdate: (index: number, field: string, 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, }; 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" 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 ? ( ) : ( 0% )}
{canDelete && ( )}
); } export default function InvoiceDetail() { const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id); const keyCounterRef = useRef(0); 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 [bankAccounts, setBankAccounts] = useState([]); const [dueDays, setDueDays] = useState(14); const [items, setItems] = useState([emptyItem()]); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); const [invoiceNumber, setInvoiceNumber] = useState(""); const [customers, setCustomers] = useState([]); const [customerSearch, setCustomerSearch] = useState(""); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const DRAFT_KEY = "boha_invoice_draft"; const clearDraft = useCallback(() => { try { localStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ } }, []); // ─── Edit mode state ─── const [invoice, setInvoice] = useState(null); 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 [deleting, setDeleting] = useState(false); const [editingItems, setEditingItems] = useState(false); const [editItems, setEditItems] = useState([]); const editKeyCounter = useRef(0); // ─── Data loading ─── useEffect(() => { if (isEdit) return; const load = async () => { try { const promises = [ apiFetch(`${API_BASE}/invoices/next-number`), apiFetch(`${API_BASE}/customers`), apiFetch(`${API_BASE}/bank-accounts`), ]; if (fromOrderId) { promises.push( apiFetch(`${API_BASE}/invoices/order-data/${fromOrderId}`), ); } const results = await Promise.all(promises); const numRes = results[0]; if (numRes.ok) { const numData = await numRes.json(); if (numData.success) setInvoiceNumber( numData.data?.next_number || numData.data?.number || "", ); } const custRes = results[1]; if (custRes.ok) { const custData = await custRes.json(); if (custData.success) setCustomers( Array.isArray(custData.data) ? custData.data : custData.data?.customers || [], ); } const bankRes = results[2]; if (bankRes.ok) { const bankData = await bankRes.json(); if (bankData.success && Array.isArray(bankData.data)) { setBankAccounts(bankData.data); const defaultAcc = bankData.data.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 && results[3]?.ok) { const orderData = await results[3].json(); if (orderData.success) { const order = orderData.data; const vatRate = Number(order.vat_rate) || 21; setForm((prev) => ({ ...prev, customer_id: order.customer_id, customer_name: order.customer_name || "", order_id: order.id, currency: order.currency || "CZK", apply_vat: Number(order.apply_vat) || 0, vat_rate: vatRate, })); if (order.items?.length > 0) { setItems( order.items.map((item: Record) => ({ _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, })), ); } } } } catch { alert.error("Chyba při načítání dat"); } finally { setLoading(false); } }; load(); }, [isEdit, fromOrderId, alert]); // Edit mode: load existing invoice const fetchDetail = useCallback(async () => { if (!id) return; try { const response = await apiFetch(`${API_BASE}/invoices/${id}`); if (response.status === 401) return; const result = await response.json(); if (result.success) { setInvoice(result.data); setNotes(result.data.notes || ""); } else { alert.error(result.error || "Nepodařilo se načíst fakturu"); navigate("/invoices"); } } catch { alert.error("Chyba připojení"); navigate("/invoices"); } finally { setLoading(false); } }, [id, alert, navigate]); useEffect(() => { if (isEdit) fetchDetail(); }, [isEdit, fetchDetail]); // ─── Create mode: due date calculation ─── useEffect(() => { if (isEdit) return; if (!form.issue_date) return; const d = new Date(form.issue_date); d.setDate(d.getDate() + dueDays); setForm((prev) => ({ ...prev, due_date: d.toISOString().split("T")[0] })); }, [isEdit, 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 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 response = await apiFetch(`${API_BASE}/invoices`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, invoice_number: invoiceNumber, items: items .filter((i) => i.description.trim()) .map((item, i) => ({ ...item, position: i, })), }), }); const result = await response.json(); if (result.success) { clearDraft(); await apiFetch( `${API_BASE}/invoices-pdf/${result.data.invoice_id}?lang=${form.language}&save=1`, ).catch(() => {}); alert.success(result.message || "Faktura byla vytvořena"); navigate(`/invoices/${result.data.invoice_id}`); } else { alert.error(result.error || "Nepodařilo se vytvořit fakturu"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; // ─── Edit mode: totals ─── const editTotals = useMemo(() => { if (!invoice?.items) return { subtotal: 0, vatByRate: {} as Record, totalVat: 0, total: 0, }; let subtotal = 0; const vatByRate: Record = {}; invoice.items.forEach((item) => { const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); subtotal += lineTotal; if (Number(invoice.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 }; }, [invoice]); // ─── 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"); fetchDetail(); } else { alert.error(result.error || "Nepodařilo se změnit stav"); } } catch { alert.error("Chyba připojení"); } finally { setStatusChanging(null); } }; // ─── Edit mode: save notes ─── const handleSaveNotes = async () => { setSaving(true); try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }), }); const result = await response.json(); if (result.success) { await apiFetch( `${API_BASE}/invoices-pdf/${id}?lang=${invoice?.language || "cs"}&save=1`, ).catch(() => {}); alert.success("Poznámky byly uloženy"); } else { alert.error(result.error || "Nepodařilo se uložit poznámky"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; // ─── Edit mode: PDF export ─── const handleViewPdf = async (_lang = "cs") => { setPdfLoading(true); try { const response = await apiFetch(`${API_BASE}/invoices/${id}/file`); if (!response.ok) { alert.error("PDF soubor nenalezen — uložte fakturu pro vygenerování"); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); window.open(url, "_blank"); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch { alert.error("Chyba připojení"); } finally { setPdfLoading(false); } }; // ─── Edit mode: edit items ─── const startEditItems = () => { if (!invoice) return; setEditItems( invoice.items.map((item) => ({ _key: `ei-${++editKeyCounter.current}`, description: item.description || "", quantity: Number(item.quantity) || 1, unit: item.unit || "", unit_price: Number(item.unit_price) || 0, vat_rate: Number(item.vat_rate) || 21, })), ); setEditingItems(true); }; const updateEditItem = ( index: number, field: string, value: string | number, ) => { setEditItems((prev) => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), ); }; const addEditItem = () => { setEditItems((prev) => [ ...prev, { _key: `ei-${++editKeyCounter.current}`, description: "", quantity: 1, unit: "ks", unit_price: 0, vat_rate: 21, }, ]); }; const removeEditItem = (index: number) => { if (editItems.length <= 1) return; setEditItems((prev) => prev.filter((_, i) => i !== index)); }; const handleEditDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setEditItems((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); }); }; const saveEditItems = async () => { setSaving(true); try { const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: editItems .filter((i) => i.description.trim()) .map((item, i) => ({ ...item, position: i })), }), }); const result = await response.json(); if (result.success) { // Regenerate PDF on NAS (fire-and-forget) await apiFetch( `${API_BASE}/invoices-pdf/${id}?lang=${invoice?.language || "cs"}&save=1`, ).catch(() => {}); alert.success("Položky byly uloženy"); setEditingItems(false); fetchDetail(); } else { alert.error(result.error || "Nepodařilo se uložit položky"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(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"); 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 ; // ─── Loading skeleton ─── if (loading) { return (
{isEdit && (
)}
{isEdit && (
)}
{[0, 1, 2, 3].map((i) => (
))}
); } // ═══════════════════════════════════════════════════════════ // CREATE MODE // ═══════════════════════════════════════════════════════════ if (!isEdit) { return (

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

{fromOrderId && (

Z objednávky

)}
{/* Basic info */}

Základní údaje

setInvoiceNumber(e.target.value)} className="admin-form-input" /> {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: "" })); }} /> {form.due_date && ( Splatnost:{" "} {new Date(form.due_date).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