Initial commit
This commit is contained in:
686
src/admin/pages/Invoices.jsx
Normal file
686
src/admin/pages/Invoices.jsx
Normal file
@@ -0,0 +1,686 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
|
||||
import apiFetch from '../utils/api'
|
||||
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
|
||||
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
|
||||
const API_BASE = '/api/admin'
|
||||
const DRAFT_KEY = 'boha_invoice_draft'
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
issued: 'Vystavena',
|
||||
paid: 'Zaplacena',
|
||||
overdue: 'Po splatnosti'
|
||||
}
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
issued: 'admin-badge-invoice-issued',
|
||||
paid: 'admin-badge-invoice-paid',
|
||||
overdue: 'admin-badge-invoice-overdue'
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'Vše' },
|
||||
{ value: 'issued', label: 'Vystavené' },
|
||||
{ value: 'paid', label: 'Zaplacené' },
|
||||
{ value: 'overdue', label: 'Po splatnosti' }
|
||||
]
|
||||
|
||||
export default function Invoices() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
|
||||
const [activeTab, setActiveTab] = useState('issued')
|
||||
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const now = new Date()
|
||||
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1)
|
||||
const [statsYear, setStatsYear] = useState(now.getFullYear())
|
||||
const [stats, setStats] = useState(null)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
const hasLoadedOnce = useRef(false)
|
||||
const slideDirection = useRef(0)
|
||||
const [slideKey, setSlideKey] = useState(0)
|
||||
|
||||
const isCurrentMonth = statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear()
|
||||
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setStatsLoading(true)
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/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)
|
||||
}
|
||||
}, [statsMonth, statsYear])
|
||||
|
||||
useEffect(() => { fetchStats() }, [fetchStats])
|
||||
|
||||
const prevMonth = () => {
|
||||
slideDirection.current = -1
|
||||
if (statsMonth === 1) {
|
||||
setStatsMonth(12)
|
||||
setStatsYear(y => y - 1)
|
||||
} else {
|
||||
setStatsMonth(m => m - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
if (isCurrentMonth) return
|
||||
slideDirection.current = 1
|
||||
if (statsMonth === 12) {
|
||||
setStatsMonth(1)
|
||||
setStatsYear(y => y + 1)
|
||||
} else {
|
||||
setStatsMonth(m => m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, invoice: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [pdfLoading, setPdfLoading] = useState(null)
|
||||
const [langModal, setLangModal] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.form && Array.isArray(parsed.items)) {
|
||||
setDraft(parsed)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const discardDraft = () => {
|
||||
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const { items: invoices, loading, refetch: fetchData } = useListData('invoices.php', {
|
||||
dataKey: 'invoices', search, sort, order,
|
||||
extraParams: statusFilter ? { status: statusFilter } : {},
|
||||
errorMsg: 'Nepodařilo se načíst faktury'
|
||||
})
|
||||
|
||||
if (!hasPermission('invoices.view')) return <Forbidden />
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.invoice) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/invoices.php?id=${deleteConfirm.invoice.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, invoice: null })
|
||||
alert.success(result.message || 'Faktura byla smazána')
|
||||
fetchData()
|
||||
fetchStats()
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat fakturu')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (inv) => {
|
||||
if (inv.status === 'paid') return
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/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 zaplacená')
|
||||
fetchData()
|
||||
fetchStats()
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se změnit stav')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePdf = async (inv, lang = 'cs') => {
|
||||
if (pdfLoading) return
|
||||
setLangModal(null)
|
||||
setPdfLoading(inv.id)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/invoices-pdf.php?id=${inv.id}&lang=${encodeURIComponent(lang)}`)
|
||||
if (response.status === 401) return
|
||||
if (!response.ok) {
|
||||
alert.error('Nepodařilo se vygenerovat PDF')
|
||||
return
|
||||
}
|
||||
const html = await response.text()
|
||||
const w = window.open('', '_blank')
|
||||
if (w) {
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.onload = () => w.print()
|
||||
} else {
|
||||
alert.error('Prohlížeč zablokoval vyskakovací okno')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba při generování PDF')
|
||||
} finally {
|
||||
setPdfLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
</div>
|
||||
<div className="dash-kpi-grid dash-kpi-4">
|
||||
{[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>
|
||||
<div className="admin-card">
|
||||
<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" style={{ width: '80px' }} />
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line" style={{ width: '70px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '90px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '90px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '100px' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Faktury</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{invoices.length} {czechPlural(invoices.length, 'faktura', 'faktury', 'faktur')}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('invoices.create') && (
|
||||
<div className="admin-page-actions">
|
||||
{activeTab === 'received' ? (
|
||||
<button className="admin-btn admin-btn-primary" onClick={() => setReceivedUploadOpen(true)}>
|
||||
<svg width="18" height="18" 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>
|
||||
Nahrát faktury
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/invoices/new" className="admin-btn admin-btn-primary">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Nová faktura
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<div className="invoice-month-nav">
|
||||
<button className="invoice-month-btn" onClick={prevMonth} aria-label="Předchozí měsíc">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
</button>
|
||||
<span>{monthLabel}</span>
|
||||
<button className="invoice-month-btn" onClick={nextMonth} disabled={isCurrentMonth} aria-label="Následující měsíc">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="offers-tabs" style={{ marginBottom: '1rem', justifyContent: 'center' }}>
|
||||
<button className={`offers-tab ${activeTab === 'issued' ? 'active' : ''}`} onClick={() => setActiveTab('issued')}>
|
||||
Vydané
|
||||
</button>
|
||||
<button className={`offers-tab ${activeTab === 'received' ? 'active' : ''}`} onClick={() => setActiveTab('received')}>
|
||||
Přijaté
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{activeTab === 'received' ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<Suspense fallback={
|
||||
<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>
|
||||
}>
|
||||
<ReceivedInvoices statsMonth={statsMonth} statsYear={statsYear} uploadOpen={receivedUploadOpen} setUploadOpen={setReceivedUploadOpen} />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
{!hasLoadedOnce.current && statsLoading ? (
|
||||
<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>
|
||||
) : stats && (
|
||||
<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 }}
|
||||
>
|
||||
{(() => {
|
||||
const paid = formatCzkWithDetail(stats.paid_month, stats.paid_month_czk)
|
||||
const wait = formatCzkWithDetail(stats.awaiting, stats.awaiting_czk)
|
||||
const over = formatCzkWithDetail(stats.overdue, stats.overdue_czk)
|
||||
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
|
||||
const countFooter = (count, zero) => count > 0
|
||||
? `${count} ${czechPlural(count, 'faktura', 'faktury', 'faktur')}`
|
||||
: zero
|
||||
return (
|
||||
<>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-label">Uhrazeno ({MONTH_NAMES[statsMonth - 1]})</div>
|
||||
<div className="admin-stat-value admin-mono">{paid.value}</div>
|
||||
<div className="admin-stat-footer">
|
||||
{[paid.detail, countFooter(stats.paid_month_count, 'žádné úhrady')].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card warning">
|
||||
<div className="admin-stat-label">Čeká úhrada <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
|
||||
<div className="admin-stat-value admin-mono">{wait.value}</div>
|
||||
<div className="admin-stat-footer">
|
||||
{[wait.detail, countFooter(stats.awaiting_count, 'vše uhrazeno')].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card danger">
|
||||
<div className="admin-stat-label">Po splatnosti <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
|
||||
<div className="admin-stat-value admin-mono">{over.value}</div>
|
||||
<div className="admin-stat-footer">
|
||||
{[over.detail, stats.overdue_count === 0 ? 'vše v pořádku' : countFooter(stats.overdue_count, '')].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-label">DPH ({MONTH_NAMES[statsMonth - 1]})</div>
|
||||
<div className="admin-stat-value admin-mono">{vat.value}</div>
|
||||
<div className="admin-stat-footer">{vat.detail || 'z vydaných faktur'}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
>
|
||||
<div className="offers-tabs" style={{ marginBottom: '1.5rem' }}>
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<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 čísla faktury, zákazníka nebo IČ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{invoices.length === 0 && !(draft && !statusFilter) ? (
|
||||
<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" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné faktury.</p>
|
||||
{hasPermission('invoices.create') && (
|
||||
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
|
||||
Vytvořte první fakturu tlačítkem výše.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
|
||||
Číslo <SortIcon column="invoice_number" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th>Zákazník</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' }}>Celkem</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{draft && !search && !statusFilter && (
|
||||
<tr className="offers-draft-row">
|
||||
<td>
|
||||
<span className="offers-draft-row-label">
|
||||
Koncept
|
||||
{draft.savedAt && (
|
||||
<span style={{ fontWeight: 400, opacity: 0.8 }}>
|
||||
{' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{draft.form.customer_name || '—'}</td>
|
||||
<td>—</td>
|
||||
<td className="admin-mono">
|
||||
{draft.form.issue_date ? formatDate(draft.form.issue_date) : '—'}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{draft.form.due_date ? formatDate(draft.form.due_date) : '—'}
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to="/invoices/new" className="admin-btn-icon" title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu">
|
||||
<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>
|
||||
</Link>
|
||||
<button
|
||||
onClick={discardDraft}
|
||||
className="admin-btn-icon danger"
|
||||
title="Zahodit koncept"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{invoices.map((inv) => {
|
||||
const isOverdue = inv.status === 'overdue' || (inv.status === 'issued' && inv.due_date && new Date(inv.due_date) < new Date(new Date().toDateString()))
|
||||
return (
|
||||
<tr key={inv.id} className={isOverdue ? 'offers-expired-row' : ''}>
|
||||
<td className="admin-mono">
|
||||
<Link to={`/invoices/${inv.id}`} className="link-accent">
|
||||
{inv.invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{inv.customer_name || '—'}</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" style={inv.status === 'overdue' ? { color: 'var(--danger)', fontWeight: 600 } : undefined}>
|
||||
{formatDate(inv.due_date)}
|
||||
</td>
|
||||
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 500 }}>
|
||||
{formatCurrency(inv.total, inv.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to={`/invoices/${inv.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
|
||||
<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>
|
||||
</Link>
|
||||
{hasPermission('invoices.export') && (
|
||||
<button
|
||||
onClick={() => setLangModal(inv)}
|
||||
className="admin-btn-icon"
|
||||
title="PDF"
|
||||
disabled={pdfLoading === inv.id}
|
||||
>
|
||||
{pdfLoading === inv.id ? (
|
||||
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
|
||||
) : (
|
||||
<svg width="18" height="18" 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" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{hasPermission('invoices.delete') && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
>
|
||||
<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>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat fakturu"
|
||||
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{langModal && (
|
||||
<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={() => setLangModal(null)} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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-body admin-confirm-content">
|
||||
<div className="admin-confirm-icon admin-confirm-icon-info">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="admin-confirm-title">Jazyk faktury</h2>
|
||||
<p className="admin-confirm-message">V jakém jazyce chcete vygenerovat fakturu?</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button type="button" onClick={() => handlePdf(langModal, 'cs')} className="admin-btn admin-btn-primary">
|
||||
Čeština
|
||||
</button>
|
||||
<button type="button" onClick={() => handlePdf(langModal, 'en')} className="admin-btn admin-btn-primary">
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user