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
}
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx
index d7d8ab8..1671310 100644
--- a/src/admin/pages/InvoiceDetail.tsx
+++ b/src/admin/pages/InvoiceDetail.tsx
@@ -1,18 +1,19 @@
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { useNavigate, useSearchParams, useParams, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
-import { useParams, useNavigate, Link } from 'react-router-dom'
+import Forbidden from '../components/Forbidden'
+import FormField from '../components/FormField'
+import AdminDatePicker from '../components/AdminDatePicker'
+import ConfirmModal from '../components/ConfirmModal'
import { motion, AnimatePresence } 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 ConfirmModal from '../components/ConfirmModal'
-import Forbidden from '../components/Forbidden'
-import FormField from '../components/FormField'
-
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
+
const API_BASE = '/api/admin'
const STATUS_LABELS: Record = {
@@ -38,6 +39,7 @@ const VAT_OPTIONS = [
interface InvoiceItem {
id?: number
+ _key: string
description: string
quantity: number
unit: string
@@ -45,8 +47,42 @@ interface InvoiceItem {
vat_rate: number
}
-interface EditItem extends InvoiceItem {
- _key: string
+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
}
interface InvoiceCustomer {
@@ -71,12 +107,76 @@ interface Invoice {
paid_date?: string
notes: string
apply_vat: number | string
- items: InvoiceItem[]
+ 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: EditItem; index: number; apply_vat: boolean;
+ item: InvoiceItem; index: number; apply_vat: boolean;
onUpdate: (index: number, field: string, value: string | number) => void;
onRemove: (index: number) => void; canDelete: boolean;
}) {
@@ -131,14 +231,77 @@ function SortableInvoiceEditRow({ item, index, apply_vat, onUpdate, onRemove, ca
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>()
- const alert = useAlert()
- const { hasPermission } = useAuth()
- const navigate = useNavigate()
+ 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 || '',
+ 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 [loading, setLoading] = useState(true)
+ const [invoiceNumber, setInvoiceNumber] = useState('')
+
+ // Customer selector (create mode)
+ const [customers, setCustomers] = useState([])
+ const [customerSearch, setCustomerSearch] = useState('')
+ const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
+
+ // Draft
+ 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 [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState(null)
const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null }>({ show: false, status: null })
const [pdfLoading, setPdfLoading] = useState(false)
@@ -146,17 +309,99 @@ export default function InvoiceDetail() {
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
- // Edit items
+ // Edit items (edit mode)
const [editingItems, setEditingItems] = useState(false)
- const [editItems, setEditItems] = useState([])
+ const [editItems, setEditItems] = useState([])
const editKeyCounter = useRef(0)
- const dndSensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
- useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
- useSensor(KeyboardSensor),
- )
+ // ─── Data loading ───
+
+ // Create mode: load next number, customers, bank accounts, order data
+ 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
@@ -177,10 +422,149 @@ export default function InvoiceDetail() {
}, [id, alert, navigate])
useEffect(() => {
- fetchDetail()
- }, [fetchDetail])
+ if (isEdit) fetchDetail()
+ }, [isEdit, fetchDetail])
- const totals = useMemo(() => {
+ // ─── 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()
+ 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 = {}
@@ -199,8 +583,7 @@ export default function InvoiceDetail() {
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }
}, [invoice])
- if (!hasPermission('invoices.view')) return
-
+ // ─── Edit mode: status change ───
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
@@ -225,6 +608,7 @@ export default function InvoiceDetail() {
}
}
+ // ─── Edit mode: save notes ───
const handleSaveNotes = async () => {
setSaving(true)
try {
@@ -246,6 +630,7 @@ export default function InvoiceDetail() {
}
}
+ // ─── Edit mode: PDF export ───
const handleViewPdf = async (lang = 'cs') => {
setLangModal(false)
const newWindow = window.open('', '_blank')
@@ -272,7 +657,7 @@ export default function InvoiceDetail() {
}
}
- // Edit items
+ // ─── Edit mode: edit items ───
const startEditItems = () => {
if (!invoice) return
setEditItems(invoice.items.map(item => ({
@@ -299,7 +684,7 @@ export default function InvoiceDetail() {
setEditItems(prev => prev.filter((_, i) => i !== index))
}
- const handleDragEnd = (event: DragEndEvent) => {
+ const handleEditDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
setEditItems(prev => {
@@ -335,6 +720,7 @@ export default function InvoiceDetail() {
}
}
+ // ─── Edit mode: delete ───
const handleDelete = async () => {
setDeleting(true)
try {
@@ -354,18 +740,25 @@ export default function InvoiceDetail() {
}
}
+ // ─── Permission checks ───
+ if (!isEdit && !hasPermission('invoices.create')) return
+ if (isEdit && !hasPermission('invoices.view')) return
+
+ // ─── Loading skeleton ───
if (loading) {
return (
@@ -381,6 +774,236 @@ export default function InvoiceDetail() {
)
}
+ // ═══════════════════════════════════════════════════════════
+ // CREATE MODE
+ // ═══════════════════════════════════════════════════════════
+ if (!isEdit) {
+ return (
+
+
+
+
+
+
+
+
+ Nová faktura {invoiceNumber && ({invoiceNumber})}
+
+ {fromOrderId &&
Z objednávky
}
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // EDIT MODE
+ // ═══════════════════════════════════════════════════════════
if (!invoice) return null
const isDraft = invoice.status === 'issued'
@@ -490,7 +1113,7 @@ export default function InvoiceDetail() {
{editingItems ? (
-
+
i._key)} strategy={verticalListSortingStrategy}>
@@ -567,9 +1190,9 @@ export default function InvoiceDetail() {
Mezisoučet:
- {formatCurrency(totals.subtotal, invoice.currency)}
+ {formatCurrency(editTotals.subtotal, invoice.currency)}
- {Number(invoice.apply_vat) > 0 && Object.entries(totals.vatByRate).map(([rate, amount]) => (
+ {Number(invoice.apply_vat) > 0 && Object.entries(editTotals.vatByRate).map(([rate, amount]) => (
DPH {rate}%:
{formatCurrency(amount, invoice.currency)}
@@ -577,7 +1200,7 @@ export default function InvoiceDetail() {
))}
Celkem k úhradě:
- {formatCurrency(totals.total, invoice.currency)}
+ {formatCurrency(editTotals.total, invoice.currency)}