- FormField.jsx: pridana podpora style prop - 23 stranek migrovano na FormField (166 vyskytu, -246 radku) - firebase/php-jwt upgrade v6.11 -> v7.0.3 (security advisory fix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
928 lines
38 KiB
JavaScript
928 lines
38 KiB
JavaScript
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 = { unpaid: 'Neuhrazena', paid: 'Uhrazena' }
|
|
const STATUS_CLASSES = { 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'
|
|
]
|
|
|
|
function formatMultiCurrency(amounts) {
|
|
if (!amounts || amounts.length === 0) { return '0 Kč' }
|
|
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
|
|
}
|
|
|
|
function formatCzkWithDetail(amounts, totalCzk) {
|
|
if (!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() {
|
|
return {
|
|
supplier_name: '',
|
|
invoice_number: '',
|
|
amount: '',
|
|
currency: 'CZK',
|
|
vat_rate: '21',
|
|
issue_date: '',
|
|
due_date: '',
|
|
notes: '',
|
|
}
|
|
}
|
|
|
|
ReceivedInvoicesProps.displayName = 'ReceivedInvoices'
|
|
|
|
export default function ReceivedInvoicesProps({ statsMonth, statsYear, uploadOpen, setUploadOpen }) {
|
|
const alert = useAlert()
|
|
const { hasPermission } = useAuth()
|
|
const { sort, order, handleSort, activeSort } = useTableSort('created_at')
|
|
const [search, setSearch] = useState('')
|
|
|
|
// Data
|
|
const [invoices, setInvoices] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [stats, setStats] = useState(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(null)
|
|
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, invoice: null })
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Upload state
|
|
const [uploadFiles, setUploadFiles] = useState([])
|
|
const [uploadMeta, setUploadMeta] = useState([])
|
|
const [uploadErrors, setUploadErrors] = useState({})
|
|
const fileInputRef = useRef(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 () => {
|
|
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.php?${params}`)
|
|
const data = await res.json()
|
|
if (data.success) {
|
|
setInvoices(data.data.invoices || [])
|
|
}
|
|
} catch { /* ignore */ } finally {
|
|
setLoading(false)
|
|
}
|
|
}, [statsMonth, statsYear, search, sort, order])
|
|
|
|
useEffect(() => { fetchList() }, [fetchList])
|
|
|
|
// Fetch stats (tiché obnovení bez animace)
|
|
const refreshStats = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/received-invoices.php?action=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 při změně měsíce (se slide animací)
|
|
useEffect(() => {
|
|
setStatsLoading(true)
|
|
const load = async () => {
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/received-invoices.php?action=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) => {
|
|
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) => {
|
|
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, field, value) => {
|
|
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 = () => {
|
|
const errors = {}
|
|
uploadMeta.forEach((m, i) => {
|
|
const e = {}
|
|
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.php`, {
|
|
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 openEdit = (inv) => {
|
|
setEditInvoice({
|
|
...inv,
|
|
amount: String(inv.amount),
|
|
vat_rate: String(inv.vat_rate),
|
|
_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.php?id=${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.php?id=${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) => {
|
|
const newWindow = window.open('', '_blank')
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/received-invoices.php?action=file&id=${inv.id}`)
|
|
if (!response.ok) {
|
|
newWindow.close()
|
|
alert.error('Nepodařilo se načíst soubor')
|
|
return
|
|
}
|
|
const blob = await response.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
newWindow.location.href = url
|
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
} catch {
|
|
newWindow.close()
|
|
alert.error('Chyba připojení')
|
|
}
|
|
}
|
|
|
|
const toggleStatus = async (inv) => {
|
|
if (inv.status === 'paid') return
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/received-invoices.php?id=${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" style={{ marginBottom: '1.5rem' }}>
|
|
{[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) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
|
|
center: { x: '0%', opacity: 1 },
|
|
exit: (dir) => ({ 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: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.1 }}
|
|
>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.15 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<div className="admin-search-bar" style={{ marginBottom: '1rem' }}>
|
|
<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].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" style={{ textAlign: 'right', fontWeight: 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 style={{ marginBottom: '1rem' }}>
|
|
<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"
|
|
className={`admin-form-input${uploadErrors[idx]?.supplier_name ? ' has-error' : ''}`}
|
|
value={uploadMeta[idx]?.supplier_name || ''}
|
|
onChange={(e) => updateMeta(idx, 'supplier_name', e.target.value)}
|
|
/>
|
|
</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(
|
|
parseFloat(uploadMeta[idx].amount || 0) * parseFloat(uploadMeta[idx].vat_rate || 21) / 100,
|
|
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) => updateMeta(idx, 'issue_date', val)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Datum splatnosti" style={{ flex: 1 }}>
|
|
<AdminDatePicker
|
|
mode="date"
|
|
value={uploadMeta[idx]?.due_date || ''}
|
|
onChange={(val) => 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"
|
|
className="admin-form-input"
|
|
value={editInvoice.supplier_name}
|
|
onChange={(e) => setEditInvoice(prev => ({ ...prev, supplier_name: e.target.value }))}
|
|
readOnly={ro}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Č. faktury">
|
|
<input
|
|
type="text"
|
|
className="admin-form-input"
|
|
value={editInvoice.invoice_number || ''}
|
|
onChange={(e) => setEditInvoice(prev => ({ ...prev, invoice_number: e.target.value }))}
|
|
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, amount: e.target.value }))}
|
|
readOnly={ro}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Měna">
|
|
<select
|
|
className="admin-form-select"
|
|
value={editInvoice.currency}
|
|
onChange={(e) => setEditInvoice(prev => ({ ...prev, currency: e.target.value }))}
|
|
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, vat_rate: e.target.value }))}
|
|
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(
|
|
parseFloat(editInvoice.amount || 0) * parseFloat(editInvoice.vat_rate || 21) / 100,
|
|
editInvoice.currency || 'CZK'
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="admin-form-row">
|
|
<FormField label="Datum vystavení">
|
|
<AdminDatePicker
|
|
mode="date"
|
|
value={editInvoice.issue_date || ''}
|
|
onChange={(val) => setEditInvoice(prev => ({ ...prev, issue_date: val }))}
|
|
disabled={ro}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Datum splatnosti">
|
|
<AdminDatePicker
|
|
mode="date"
|
|
value={editInvoice.due_date || ''}
|
|
onChange={(val) => setEditInvoice(prev => ({ ...prev, due_date: val }))}
|
|
disabled={ro}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
<FormField label="Stav">
|
|
<select
|
|
className="admin-form-select"
|
|
value={editInvoice.status}
|
|
onChange={(e) => setEditInvoice(prev => ({ ...prev, status: e.target.value }))}
|
|
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, notes: e.target.value }))}
|
|
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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|