From 2540efbec2776536348bb25a8b0b94c8bff98bee Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 19:34:16 +0100 Subject: [PATCH] refactor: merge InvoiceCreate into InvoiceDetail (single page for create + edit) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/admin/AdminApp.tsx | 3 +- src/admin/pages/InvoiceCreate.tsx | 659 ---------------------------- src/admin/pages/InvoiceDetail.tsx | 697 ++++++++++++++++++++++++++++-- 3 files changed, 661 insertions(+), 698 deletions(-) delete mode 100644 src/admin/pages/InvoiceCreate.tsx diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 455d3a8..dc40df8 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -39,7 +39,6 @@ const Projects = lazy(() => import('./pages/Projects')) const ProjectCreate = lazy(() => import('./pages/ProjectCreate')) const ProjectDetail = lazy(() => import('./pages/ProjectDetail')) const Invoices = lazy(() => import('./pages/Invoices')) -const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate')) const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail')) const Settings = lazy(() => import('./pages/Settings')) const AuditLog = lazy(() => import('./pages/AuditLog')) @@ -81,7 +80,7 @@ export default function AdminApp() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/admin/pages/InvoiceCreate.tsx b/src/admin/pages/InvoiceCreate.tsx deleted file mode 100644 index 0d8a2a1..0000000 --- a/src/admin/pages/InvoiceCreate.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react' -import { useNavigate, useSearchParams, Link } from 'react-router-dom' -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 { 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 } from '../utils/formatters' - -const API_BASE = '/api/admin' - -const VAT_OPTIONS = [ - { value: 21, label: '21%' }, - { value: 12, label: '12%' }, - { value: 0, label: '0%' } -] - -interface InvoiceItem { - _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 - notes: string - bank_account_id: number | string - bank_name: string - bank_swift: string - bank_iban: string - bank_account: string -} - -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 && ( - - )} - - - ) -} - -export default function InvoiceCreate() { - 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() - - const rawOrderId = searchParams.get('fromOrder') - const fromOrderId = 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 || '', - notes: '', - 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 [loadingInit, setLoadingInit] = useState(true) - const [invoiceNumber, setInvoiceNumber] = useState('') - - // Customer selector - const [customers, setCustomers] = useState([]) - const [customerSearch, setCustomerSearch] = useState('') - const [showCustomerDropdown, setShowCustomerDropdown] = useState(false) - - // Draft - const DRAFT_KEY = 'boha_invoice_draft' - const isManual = !fromOrderId - - const clearDraft = useCallback(() => { - try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ } - }, []) - - // Load init data - useEffect(() => { - 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 { - setLoadingInit(false) - } - } - load() - }, [fromOrderId, alert]) - - // Due date calculation - useEffect(() => { - 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] })) - }, [form.issue_date, dueDays]) - - // 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) - } - - // 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 handleDragEnd = (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) - }) - } - - // Totals - const totals = 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]) - - const handleSubmit = 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() - 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) - } - } - - if (!hasPermission('invoices.create')) return - - if (loadingInit) { - return ( -
-
-
-
-
-
- {[0, 1, 2, 3].map(i => ( -
-
-
-
- ))} -
-
-
- ) - } - - 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, 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} - /> - ))} - -
- #PopisMnožstvíJednotkaJedn. cenaDPHCelkem
-
-
-
- - {/* Totals */} -
-
- Mezisoučet: - {formatCurrency(totals.subtotal, form.currency)} -
- {form.apply_vat && Object.entries(totals.vatByRate).map(([rate, amount]) => ( -
- DPH {rate}%: - {formatCurrency(amount, form.currency)} -
- ))} -
- Celkem k úhradě: - {formatCurrency(totals.total, form.currency)} -
-
-
-
- - {/* Notes */} - -

Veřejné poznámky na faktuře

-