diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index cfd0cec..db52871 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -114,6 +114,7 @@ interface InvoiceCustomer { interface Invoice { id: number; invoice_number: string; + customer_id?: number | null; customer_name: string | null; customer?: InvoiceCustomer; order_id?: number; @@ -124,11 +125,19 @@ interface Invoice { 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[]; } @@ -300,172 +309,6 @@ function SortableInvoiceRow({ ); } -// Sortable row for edit mode (existing invoice items) -function SortableInvoiceEditRow({ - item, - index, - apply_vat, - vatOptions, - onUpdate, - onRemove, - canDelete, -}: { - item: InvoiceItem; - index: number; - apply_vat: boolean; - vatOptions: { value: number; label: string }[]; - 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); @@ -597,10 +440,6 @@ export default function InvoiceDetail() { 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(() => { @@ -706,12 +545,90 @@ export default function InvoiceDetail() { const fetchDetail = useCallback(async () => { if (!id) return; try { - const response = await apiFetch(`${API_BASE}/invoices/${id}`); + const [response, custRes, bankRes] = await Promise.all([ + apiFetch(`${API_BASE}/invoices/${id}`), + apiFetch(`${API_BASE}/customers`), + apiFetch(`${API_BASE}/bank-accounts`), + ]); if (response.status === 401) return; const result = await response.json(); if (result.success) { - setInvoice(result.data); - setNotes(result.data.notes || ""); + const inv = result.data; + setInvoice(inv); + setNotes(inv.notes || ""); + setInvoiceNumber(inv.invoice_number || ""); + + // Populate customers list + if (custRes.ok) { + const custData = await custRes.json(); + if (custData.success) + setCustomers( + Array.isArray(custData.data) + ? custData.data + : custData.data?.customers || [], + ); + } + + // Populate bank accounts and match existing + let matchedBankId: number | string = ""; + if (bankRes.ok) { + const bankData = await bankRes.json(); + if (bankData.success && Array.isArray(bankData.data)) { + setBankAccounts(bankData.data); + // Match by IBAN or account number + const match = bankData.data.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; + } + } + + // Populate form state from existing invoice + setForm({ + customer_id: inv.customer_id || null, + customer_name: inv.customer_name || "", + order_id: inv.order_id || null, + issue_date: inv.issue_date + ? new Date(inv.issue_date).toISOString().split("T")[0] + : "", + due_date: inv.due_date + ? new Date(inv.due_date).toISOString().split("T")[0] + : "", + tax_date: inv.tax_date + ? new Date(inv.tax_date).toISOString().split("T")[0] + : "", + currency: inv.currency || "CZK", + apply_vat: Number(inv.apply_vat) ? 1 : 0, + vat_rate: Number(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 || "", + notes: inv.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 || "", + }); + + // Populate items from existing invoice + if (inv.items?.length > 0) { + setItems( + inv.items.map((item: Record) => ({ + _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, + })), + ); + } } else { alert.error(result.error || "Nepodařilo se načíst fakturu"); navigate("/invoices"); @@ -841,7 +758,7 @@ export default function InvoiceDetail() { return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }; }, [items, form.apply_vat]); - // ─── Create mode: submit ─── + // ─── Create/Edit mode: submit ─── const handleCreateSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -859,30 +776,50 @@ export default function InvoiceDetail() { setSaving(true); try { - const response = await apiFetch(`${API_BASE}/invoices`, { - method: "POST", + const payload = { + ...form, + invoice_number: invoiceNumber, + items: items + .filter((i) => i.description.trim()) + .map((item, i) => ({ + ...item, + position: i, + })), + }; + + 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({ - ...form, - invoice_number: invoiceNumber, - items: items - .filter((i) => i.description.trim()) - .map((item, i) => ({ - ...item, - position: i, - })), - }), + body: JSON.stringify(payload), }); const result = await response.json(); if (result.success) { - clearDraft(); + if (!isEdit) clearDraft(); + const invoiceId = isEdit ? id : result.data.invoice_id; await apiFetch( - `${API_BASE}/invoices-pdf/${result.data.invoice_id}?lang=${form.language}&save=1`, + `${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`, ).catch(() => {}); - alert.success(result.message || "Faktura byla vytvořena"); - navigate(`/invoices/${result.data.invoice_id}`); + alert.success( + result.message || + (isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"), + ); + if (isEdit) { + fetchDetail(); + } else { + navigate(`/invoices/${result.data.invoice_id}`); + } } else { - alert.error(result.error || "Nepodařilo se vytvořit fakturu"); + alert.error( + result.error || + (isEdit + ? "Nepodařilo se uložit fakturu" + : "Nepodařilo se vytvořit fakturu"), + ); } } catch { alert.error("Chyba připojení"); @@ -891,33 +828,6 @@ export default function InvoiceDetail() { } }; - // ─── 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; @@ -943,31 +853,6 @@ export default function InvoiceDetail() { } }; - // ─── 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") => { const newWindow = window.open("", "_blank"); @@ -991,94 +876,6 @@ export default function InvoiceDetail() { } }; - // ─── 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) || (companySettings?.default_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: companySettings?.default_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); @@ -1153,11 +950,16 @@ export default function InvoiceDetail() { } // ═══════════════════════════════════════════════════════════ - // CREATE MODE + // PAID INVOICE — read-only view // ═══════════════════════════════════════════════════════════ - if (!isEdit) { + const isPaid = isEdit && invoice?.status === "paid"; + + if (isEdit && !invoice) return null; + + if (isPaid && invoice) { return (
+ {/* Header */}
-

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

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

- {fromOrderId && ( -

Z objednávky

- )}
- + ) : ( + + + + + )} + Zobrazit fakturu + + )} + {hasPermission("invoices.delete") && ( + + )}
-
- {/* 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: "" })); - }} - /> - -
- -
- - - - - - - - - - -
- + {invoice.customer.company_id && + `IČ: ${invoice.customer.company_id}`} + {invoice.customer.vat_id && + ` · DIČ: ${invoice.customer.vat_id}`}
-
-
- - - + )} -
-
- - {/* Items */} - -
-
+
-

Položky

- {errors.items && ( - {errors.items} + {invoice.order_id ? ( + + {invoice.order_number} + + ) : ( + "\u2014" )}
- -
- - - i._key)} - strategy={verticalListSortingStrategy} - > -
- - - - - - - - - {form.apply_vat ? ( - - ) : null} - - - - - - {items.map((item, index) => ( - 1} - /> - ))} - -
- - # - Popis - Množství - - Jednotka - - Jedn. cena - - DPH - - Celkem -
+ + +
{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)}
- - +
+
+ )} +
+
- {/* 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)} - -
+ {/* 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 */} - -

Veřejné poznámky na faktuře

-