Files
app/src/admin/pages/ReceivedInvoices.tsx
BOHA c817e004b7 feat: supplier name autocomplete on received invoices
- Added GET /api/admin/received-invoices/suppliers endpoint (distinct names)
- Upload and edit forms use HTML datalist for browser-native autocomplete
- Suggestions loaded once on page mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:32:38 +01:00

1018 lines
40 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useModalLock from '../hooks/useModalLock'
import AdminDatePicker from '../components/AdminDatePicker'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = { unpaid: 'Neuhrazena', paid: 'Uhrazena' }
const STATUS_CLASSES: Record<string, string> = { unpaid: 'admin-badge-invoice-overdue', paid: 'admin-badge-invoice-paid' }
const CURRENCY_OPTIONS = ['CZK', 'EUR', 'USD', 'GBP']
const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21]
const MONTH_NAMES = [
'leden', 'únor', 'březen', 'duben', 'květen', 'červen',
'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec'
]
interface CurrencyAmount {
amount: number
currency: string
}
interface ReceivedInvoice {
id: number
supplier_name: string
invoice_number: string
amount: number
currency: string
vat_rate: number
issue_date: string
due_date: string
notes: string
status: string
file_name?: string
created_at: string
}
interface ReceivedStats {
total_month: CurrencyAmount[]
total_month_czk: number | null
vat_month: CurrencyAmount[]
vat_month_czk: number | null
unpaid: CurrencyAmount[]
unpaid_czk: number | null
unpaid_count: number
month_count: number
}
interface UploadMeta {
supplier_name: string
invoice_number: string
amount: string
currency: string
vat_rate: string
issue_date: string
due_date: string
notes: string
}
interface EditInvoice extends Omit<ReceivedInvoice, 'amount' | 'vat_rate'> {
amount: string
vat_rate: string
_originalStatus: string
}
interface UploadErrors {
[idx: number]: {
[field: string]: string
}
}
interface ReceivedInvoicesProps {
statsMonth: number
statsYear: number
uploadOpen: boolean
setUploadOpen: (open: boolean) => void
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) { return '0 Kč' }
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } {
if (!Array.isArray(amounts) || amounts.length === 0) { return { value: '0 Kč', detail: null } }
const hasForeign = amounts.some(a => a.currency !== 'CZK')
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
return { value: formatCurrency(totalCzk, 'CZK'), detail: formatMultiCurrency(amounts) }
}
return { value: formatMultiCurrency(amounts), detail: null }
}
function emptyMeta(): UploadMeta {
return {
supplier_name: '',
invoice_number: '',
amount: '',
currency: 'CZK',
vat_rate: '21',
issue_date: '',
due_date: '',
notes: '',
}
}
export default function ReceivedInvoices({ statsMonth, statsYear, uploadOpen, setUploadOpen }: ReceivedInvoicesProps) {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('created_at')
const [search, setSearch] = useState('')
// Data
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<ReceivedStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const hasLoadedOnce = useRef(false)
const slideDirection = useRef(0)
const [slideKey, setSlideKey] = useState(0)
const prevMonth = useRef(statsMonth)
const prevYear = useRef(statsYear)
// Modals
const [editOpen, setEditOpen] = useState(false)
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: ReceivedInvoice | null }>({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [saving, setSaving] = useState(false)
// Supplier autocomplete
const [supplierNames, setSupplierNames] = useState<string[]>([])
// Upload state
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([])
const [uploadErrors, setUploadErrors] = useState<UploadErrors>({})
const fileInputRef = useRef<HTMLInputElement>(null)
useModalLock(uploadOpen || editOpen)
// Slide direction detection
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current
const curr = statsYear * 12 + statsMonth
if (curr > prev) { slideDirection.current = 1 }
if (curr < prev) { slideDirection.current = -1 }
prevMonth.current = statsMonth
prevYear.current = statsYear
}, [statsMonth, statsYear])
// Fetch list
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true)
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
})
if (search) { params.set('search', search) }
if (sort) { params.set('sort', sort) }
if (order) { params.set('order', order) }
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`)
const data = await res.json()
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : [])
}
} catch { /* ignore */ } finally {
setLoading(false)
hasLoadedOnce.current = true
}
}, [statsMonth, statsYear, search, sort, order])
useEffect(() => { fetchList() }, [fetchList])
// Fetch supplier names for autocomplete
useEffect(() => {
apiFetch(`${API_BASE}/received-invoices/suppliers`)
.then(r => r.json())
.then(d => { if (d.success) setSupplierNames(d.data || []) })
.catch(() => {})
}, [])
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
}
} catch { /* ignore */ }
}, [statsMonth, statsYear])
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true)
const load = async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
setSlideKey(k => k + 1)
}
} catch { /* ignore */ } finally {
setStatsLoading(false)
}
}
load()
}, [statsMonth, statsYear])
// Upload handlers
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || [])
if (selected.length === 0) { return }
if (uploadFiles.length + selected.length > 20) {
alert.error('Maximálně 20 souborů najednou')
return
}
const valid = selected.filter(f => {
if (f.size > 10 * 1024 * 1024) {
alert.error(`Soubor "${f.name}" je větší než 10 MB`)
return false
}
const allowed = ['application/pdf', 'image/jpeg', 'image/png']
if (!allowed.includes(f.type)) {
alert.error(`Soubor "${f.name}": nepodporovaný formát`)
return false
}
return true
})
setUploadFiles(prev => [...prev, ...valid])
setUploadMeta(prev => [...prev, ...valid.map(() => emptyMeta())])
e.target.value = ''
}
const removeUploadFile = (idx: number) => {
setUploadFiles(prev => prev.filter((_, i) => i !== idx))
setUploadMeta(prev => prev.filter((_, i) => i !== idx))
const newErrors = { ...uploadErrors }
delete newErrors[idx]
setUploadErrors(newErrors)
}
const updateMeta = (idx: number, field: keyof UploadMeta, value: string) => {
setUploadMeta(prev => prev.map((m, i) => i === idx ? { ...m, [field]: value } : m))
if (uploadErrors[idx]) {
const newErrors = { ...uploadErrors }
if (newErrors[idx]?.[field]) {
delete newErrors[idx][field]
if (Object.keys(newErrors[idx]).length === 0) { delete newErrors[idx] }
}
setUploadErrors(newErrors)
}
}
const validateUpload = (): boolean => {
const errors: UploadErrors = {}
uploadMeta.forEach((m, i) => {
const e: Record<string, string> = {}
if (!m.supplier_name.trim()) { e.supplier_name = 'Povinné pole' }
if (!m.amount || parseFloat(m.amount) <= 0) { e.amount = 'Částka musí být větší než 0' }
if (Object.keys(e).length > 0) { errors[i] = e }
})
setUploadErrors(errors)
return Object.keys(errors).length === 0
}
const handleUploadSave = async () => {
if (uploadFiles.length === 0) {
alert.error('Vyberte alespoň jeden soubor')
return
}
if (!validateUpload()) { return }
setSaving(true)
try {
const formData = new FormData()
uploadFiles.forEach(f => formData.append('files[]', f))
formData.append('invoices', JSON.stringify(uploadMeta))
const res = await apiFetch(`${API_BASE}/received-invoices`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktury byly nahrány')
setUploadOpen(false)
setUploadFiles([])
setUploadMeta([])
setUploadErrors({})
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při nahrávání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Edit handlers
const toDateInput = (d: string | null | undefined): string => {
if (!d) return ''
const date = new Date(d)
if (isNaN(date.getTime())) return ''
return date.toISOString().split('T')[0]
}
const openEdit = (inv: ReceivedInvoice) => {
setEditInvoice({
...inv,
amount: String(inv.amount),
vat_rate: String(inv.vat_rate),
issue_date: toDateInput(inv.issue_date),
due_date: toDateInput(inv.due_date),
_originalStatus: inv.status,
})
setEditOpen(true)
}
const handleEditSave = async () => {
if (!editInvoice) { return }
if (!editInvoice.supplier_name?.trim()) {
alert.error('Dodavatel je povinný')
return
}
if (!editInvoice.amount || parseFloat(editInvoice.amount) <= 0) {
alert.error('Částka musí být větší než 0')
return
}
setSaving(true)
try {
const payload = {
supplier_name: editInvoice.supplier_name,
invoice_number: editInvoice.invoice_number || '',
amount: parseFloat(editInvoice.amount),
currency: editInvoice.currency,
vat_rate: parseFloat(editInvoice.vat_rate),
issue_date: editInvoice.issue_date || '',
due_date: editInvoice.due_date || '',
notes: editInvoice.notes || '',
status: editInvoice.status,
}
const res = await apiFetch(`${API_BASE}/received-invoices/${editInvoice.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla aktualizována')
setEditOpen(false)
setEditInvoice(null)
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při ukládání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Delete
const handleDelete = async () => {
if (!deleteConfirm.invoice) { return }
setDeleting(true)
try {
const res = await apiFetch(`${API_BASE}/received-invoices/${deleteConfirm.invoice.id}`, {
method: 'DELETE',
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla smazána')
setDeleteConfirm({ show: false, invoice: null })
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při mazání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
// View file
const openFile = async (inv: ReceivedInvoice) => {
const newWindow = window.open('', '_blank')
try {
const response = await apiFetch(`${API_BASE}/received-invoices/${inv.id}/file`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se načíst soubor')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
if (newWindow) { newWindow.location.href = url }
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow?.close()
alert.error('Chyba připojení')
}
}
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/received-invoices/${inv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paid' }),
})
const data = await res.json()
if (data.success) {
alert.success('Faktura označena jako uhrazená')
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
}
}
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`
// KPI
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
return (
<div className="dash-kpi-grid dash-kpi-4 mb-6">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
)
}
if (!stats) { return null }
const total = formatCzkWithDetail(stats.total_month, stats.total_month_czk)
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
const unpaid = formatCzkWithDetail(stats.unpaid, stats.unpaid_czk)
return (
<div style={{ overflow: 'hidden', marginBottom: '1.5rem' }}>
<AnimatePresence mode="popLayout" initial={false} custom={slideDirection.current}>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir: number) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div className="admin-stat-card success">
<div className="admin-stat-label">Celkem ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{total.value}</div>
<div className="admin-stat-footer">
{total.detail || `${stats.month_count} ${czechPlural(stats.month_count, 'faktura', 'faktury', 'faktur')}`}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">DPH k odpočtu ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">{vat.detail || 'z přijatých faktur'}</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Neuhrazeno <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{unpaid.value}</div>
<div className="admin-stat-footer">
{unpaid.detail || (stats.unpaid_count === 0
? 'vše uhrazeno'
: `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, 'faktura', 'faktury', 'faktur')}`
)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Počet ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{stats.month_count}</div>
<div className="admin-stat-footer">
{stats.month_count === 0 ? 'žádné faktury' : `přijatých faktur`}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
)
}
return (
<>
{renderKpi()}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle dodavatele nebo čísla faktury..."
/>
</div>
{loading && (
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && invoices.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission('invoices.create') && (
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Nahrajte faktury tlačítkem výše.
</p>
)}
</div>
)}
{!loading && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('supplier_name')}>
Dodavatel <SortIcon column="supplier_name" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
Č. faktury <SortIcon column="invoice_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('issue_date')}>
Vystaveno <SortIcon column="issue_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('due_date')}>
Splatnost <SortIcon column="due_date" sort={activeSort} order={order} />
</th>
<th style={{ textAlign: 'right', cursor: 'pointer' }} onClick={() => handleSort('amount')}>
Částka <SortIcon column="amount" sort={activeSort} order={order} />
</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.supplier_name}</td>
<td className="admin-mono">
{inv.invoice_number ? (
<span className="link-accent" style={{ cursor: 'pointer' }} onClick={() => openFile(inv)}>
{inv.invoice_number}
</span>
) : '—'}
</td>
<td>
{inv.status === 'paid' ? (
<span className={`admin-badge ${STATUS_CLASSES[inv.status]}`}>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ''}`}
style={{ cursor: 'pointer' }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">{formatDate(inv.issue_date)}</td>
<td className="admin-mono">{formatDate(inv.due_date)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(inv.amount, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
{inv.file_name && (
<button className="admin-btn-icon" title="Zobrazit soubor" onClick={() => openFile(inv)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
{hasPermission('invoices.edit') && (
<button className="admin-btn-icon" title="Upravit" onClick={() => openEdit(inv)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{hasPermission('invoices.delete') && (
<button
className="admin-btn-icon danger"
title="Smazat"
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Upload Modal */}
<AnimatePresence>
{uploadOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !saving && setUploadOpen(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Nahrát přijaté faktury</h2>
</div>
<div className="admin-modal-body">
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubory
</button>
<span style={{ marginLeft: '0.75rem', fontSize: '0.8125rem', color: 'var(--text-tertiary)' }}>
PDF, JPEG, PNG · max 10 MB · max 20 souborů
</span>
</div>
{uploadFiles.length === 0 && (
<div className="admin-empty-state" style={{ padding: '2rem 0' }}>
<p style={{ color: 'var(--text-tertiary)' }}>Zatím nebyly vybrány žádné soubory.</p>
</div>
)}
<div className="received-upload-list">
{uploadFiles.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="received-upload-card">
<div className="received-upload-card-header">
<div className="received-upload-file-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="received-upload-file-name">{file.name}</span>
<span className="received-upload-file-size">{Math.round(file.size / 1024)} KB</span>
</div>
<button className="admin-btn-icon danger" style={{ width: '24px', height: '24px' }} onClick={() => removeUploadFile(idx)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="received-upload-card-fields">
<FormField label="Dodavatel" error={uploadErrors[idx]?.supplier_name} required>
<input
type="text"
list="supplier-suggestions"
className={`admin-form-input${uploadErrors[idx]?.supplier_name ? ' has-error' : ''}`}
value={uploadMeta[idx]?.supplier_name || ''}
onChange={(e) => updateMeta(idx, 'supplier_name', e.target.value)}
autoComplete="off"
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.invoice_number || ''}
onChange={(e) => updateMeta(idx, 'invoice_number', e.target.value)}
/>
</FormField>
<div className="received-upload-row">
<FormField label="Částka" error={uploadErrors[idx]?.amount} required style={{ flex: 1 }}>
<input
type="number"
step="0.01"
min="0"
className={`admin-form-input${uploadErrors[idx]?.amount ? ' has-error' : ''}`}
value={uploadMeta[idx]?.amount || ''}
onChange={(e) => updateMeta(idx, 'amount', e.target.value)}
/>
</FormField>
<FormField label="Měna" style={{ width: '90px' }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.currency || 'CZK'}
onChange={(e) => updateMeta(idx, 'currency', e.target.value)}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FormField>
<FormField label="DPH %" style={{ width: '90px' }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.vat_rate || '21'}
onChange={(e) => updateMeta(idx, 'vat_rate', e.target.value)}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</FormField>
</div>
{uploadMeta[idx]?.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginTop: '-0.25rem', marginBottom: '0.5rem' }}>
DPH: {formatCurrency(
(() => { const a = parseFloat(uploadMeta[idx].amount || '0'); const r = parseFloat(uploadMeta[idx].vat_rate || '21'); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(),
uploadMeta[idx].currency || 'CZK'
)}
</div>
)}
<div className="received-upload-row">
<FormField label="Datum vystavení" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.issue_date || ''}
onChange={(val: string) => updateMeta(idx, 'issue_date', val)}
/>
</FormField>
<FormField label="Datum splatnosti" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.due_date || ''}
onChange={(val: string) => updateMeta(idx, 'due_date', val)}
/>
</FormField>
</div>
<FormField label="Poznámka">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.notes || ''}
onChange={(e) => updateMeta(idx, 'notes', e.target.value)}
/>
</FormField>
</div>
</div>
))}
</div>
</div>
<div className="admin-modal-footer">
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setUploadOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleUploadSave} disabled={saving || uploadFiles.length === 0}>
{saving ? 'Nahrávání...' : 'Uložit vše'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Edit Modal */}
<AnimatePresence>
{editOpen && editInvoice && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !saving && setEditOpen(false)} />
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{(() => {
const ro = editInvoice._originalStatus === 'paid'
return (
<>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{ro ? 'Detail přijaté faktury' : 'Upravit přijatou fakturu'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Dodavatel" required>
<input
type="text"
list="supplier-suggestions"
className="admin-form-input"
value={editInvoice.supplier_name}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, supplier_name: e.target.value } : null)}
readOnly={ro}
autoComplete="off"
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={editInvoice.invoice_number || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, invoice_number: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<div className="admin-form-row admin-form-row-3">
<FormField label="Částka" required>
<input
type="number"
step="0.01"
min="0"
className="admin-form-input"
value={editInvoice.amount}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, amount: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<FormField label="Měna">
<select
className="admin-form-select"
value={editInvoice.currency}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, currency: e.target.value } : null)}
disabled={ro}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FormField>
<FormField label="DPH %">
<select
className="admin-form-select"
value={editInvoice.vat_rate}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, vat_rate: e.target.value } : null)}
disabled={ro}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</FormField>
</div>
{editInvoice.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.75rem' }}>
DPH: {formatCurrency(
(() => { const a = parseFloat(editInvoice.amount || '0'); const r = parseFloat(editInvoice.vat_rate || '21'); return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; })(),
editInvoice.currency || 'CZK'
)}
</div>
)}
<div className="admin-form-row">
<FormField label="Datum vystavení">
<AdminDatePicker
mode="date"
value={editInvoice.issue_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, issue_date: val } : null)}
disabled={ro}
/>
</FormField>
<FormField label="Datum splatnosti">
<AdminDatePicker
mode="date"
value={editInvoice.due_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, due_date: val } : null)}
disabled={ro}
/>
</FormField>
</div>
<FormField label="Stav">
<select
className="admin-form-select"
value={editInvoice.status}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, status: e.target.value } : null)}
disabled={ro}
>
<option value="unpaid">Neuhrazena</option>
<option value="paid">Uhrazena</option>
</select>
</FormField>
<FormField label="Poznámka">
<textarea
className="admin-form-input"
rows={3}
value={editInvoice.notes || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, notes: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
{ro ? (
<button className="admin-btn admin-btn-secondary" onClick={() => setEditOpen(false)}>
Zavřít
</button>
) : (
<>
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setEditOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleEditSave} disabled={saving}>
{saving ? 'Ukládání...' : 'Uložit'}
</button>
</>
)}
</div>
</>
)
})()}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat přijatou fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.supplier_name || ''}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<datalist id="supplier-suggestions">
{supplierNames.map(name => (
<option key={name} value={name} />
))}
</datalist>
</>
)
}