Root cause: useListData set loading=true on every refetch, and all 4 admin list pages (offers, orders, invoices, projects) applied pointerEvents:'none' while loading — blocking all clicks including sort column headers. Fix: removed setLoading(true) from refetch (matching PHP behavior) and removed pointerEvents from all list page cards. Opacity fade kept as visual feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
652 lines
31 KiB
TypeScript
652 lines
31 KiB
TypeScript
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'
|
|
import Pagination from '../components/Pagination'
|
|
|
|
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'
|
|
]
|
|
|
|
interface CurrencyAmount {
|
|
amount: number
|
|
currency: string
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
issued: 'Vystavena',
|
|
paid: 'Zaplacena',
|
|
overdue: 'Po splatnosti'
|
|
}
|
|
|
|
const STATUS_CLASSES: Record<string, string> = {
|
|
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' }
|
|
]
|
|
|
|
interface Invoice {
|
|
id: number
|
|
invoice_number: string
|
|
customer_name: string | null
|
|
status: string
|
|
issue_date: string
|
|
due_date: string
|
|
total: number
|
|
currency: string
|
|
}
|
|
|
|
interface InvoiceStats {
|
|
paid_month: CurrencyAmount[]
|
|
paid_month_czk: number
|
|
paid_month_count: number
|
|
awaiting: CurrencyAmount[]
|
|
awaiting_czk: number
|
|
awaiting_count: number
|
|
overdue: CurrencyAmount[]
|
|
overdue_czk: number
|
|
overdue_count: number
|
|
vat_month: CurrencyAmount[]
|
|
vat_month_czk: number
|
|
}
|
|
|
|
interface DraftData {
|
|
form: Record<string, unknown>
|
|
items: Record<string, unknown>[]
|
|
savedAt?: string
|
|
}
|
|
|
|
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 [page, setPage] = useState(1)
|
|
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<InvoiceStats | null>(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/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: boolean; invoice: Invoice | null }>({ show: false, invoice: null })
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
|
|
const [langModal, setLangModal] = useState<Invoice | null>(null)
|
|
const [draft, setDraft] = useState<DraftData | null>(() => {
|
|
try {
|
|
const raw = localStorage.getItem(DRAFT_KEY)
|
|
if (!raw) return null
|
|
const parsed = JSON.parse(raw) as DraftData
|
|
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
|
|
} catch { /* ignore */ }
|
|
return null
|
|
})
|
|
|
|
const discardDraft = () => {
|
|
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
|
setDraft(null)
|
|
}
|
|
|
|
const { items: invoices, loading, initialLoad, pagination, refetch: fetchData } = useListData<Invoice>('invoices', {
|
|
search, sort, order, page,
|
|
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/${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: Invoice) => {
|
|
if (inv.status === 'paid') return
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/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 zaplacená')
|
|
fetchData()
|
|
fetchStats()
|
|
} else {
|
|
alert.error(data.error || 'Nepodařilo se změnit stav')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
}
|
|
}
|
|
|
|
const handlePdf = async (inv: Invoice, lang = 'cs') => {
|
|
if (pdfLoading) return
|
|
setLangModal(null)
|
|
setPdfLoading(inv.id)
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/invoices-pdf/${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 (initialLoad) {
|
|
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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
|
|
<div>
|
|
<h1 className="admin-page-title">Faktury</h1>
|
|
<p className="admin-page-subtitle">
|
|
{pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
|
|
<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 mb-4" style={{ 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, 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: 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 }}
|
|
>
|
|
{(() => {
|
|
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: number, zero: string) => 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
|
|
<div className="offers-tabs mb-6">
|
|
{STATUS_FILTERS.map(f => (
|
|
<button
|
|
key={f.value}
|
|
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
|
|
onClick={() => { setStatusFilter(f.value); setPage(1) }}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}
|
|
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}>
|
|
<div className="admin-card-body">
|
|
<div className="admin-search-bar mb-4">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
|
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 className="text-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 as string) || '\u2014'}</td>
|
|
<td>{'\u2014'}</td>
|
|
<td className="admin-mono">
|
|
{draft.form.issue_date ? formatDate(draft.form.issue_date as string) : '\u2014'}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{draft.form.due_date ? formatDate(draft.form.due_date as string) : '\u2014'}
|
|
</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 || '\u2014'}</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>
|
|
)}
|
|
<Pagination pagination={pagination} onPageChange={setPage} />
|
|
</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>
|
|
)
|
|
}
|