initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
656
src/admin/pages/Offers.tsx
Normal file
656
src/admin/pages/Offers.tsx
Normal file
@@ -0,0 +1,656 @@
|
||||
import { useState } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link, useNavigate } 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 useModalLock from '../hooks/useModalLock'
|
||||
import Pagination from '../components/Pagination'
|
||||
import FormField from '../components/FormField'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const DRAFT_KEY = 'boha_offer_draft'
|
||||
|
||||
interface Quotation {
|
||||
id: number
|
||||
quotation_number: string
|
||||
project_code: string
|
||||
customer_name: string
|
||||
created_at: string
|
||||
valid_until: string
|
||||
currency: string
|
||||
total: number
|
||||
status: string
|
||||
order_id?: number
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
form: {
|
||||
project_code: string
|
||||
customer_name: string
|
||||
created_at: string
|
||||
valid_until: string
|
||||
currency: string
|
||||
}
|
||||
items: unknown[]
|
||||
savedAt?: string
|
||||
}
|
||||
|
||||
export default function Offers() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
|
||||
const [invalidating, setInvalidating] = useState(false)
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null)
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null)
|
||||
const [orderModal, setOrderModal] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
|
||||
useModalLock(orderModal.show)
|
||||
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
|
||||
const [draft, setDraft] = useState<Draft | null>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
|
||||
} catch { /* ignore corrupt data */ }
|
||||
return null
|
||||
})
|
||||
|
||||
const { items: quotations, loading, initialLoad, pagination, refetch: fetchData } = useListData('offers', {
|
||||
search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst nabídky'
|
||||
})
|
||||
|
||||
const discardDraft = () => {
|
||||
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const getRowClass = (invalidated: boolean, expired: boolean) => {
|
||||
if (invalidated) return 'offers-invalidated-row'
|
||||
if (expired) return 'offers-expired-row'
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!hasPermission('offers.view')) return <Forbidden />
|
||||
|
||||
const handleDuplicate = async (quotation: Quotation) => {
|
||||
setDuplicating(quotation.id)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/offers/${quotation.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
alert.success(result.message || 'Nabídka byla duplikována')
|
||||
fetchData()
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se duplikovat nabídku')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setDuplicating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
if (!customerOrderNumber.trim() || !orderModal.quotation) return
|
||||
setCreatingOrder(orderModal.quotation.id)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('quotationId', String(orderModal.quotation.id))
|
||||
formData.append('customerOrderNumber', customerOrderNumber.trim())
|
||||
if (orderAttachment) {
|
||||
formData.append('attachment', orderAttachment)
|
||||
}
|
||||
const response = await apiFetch(`${API_BASE}/orders`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setOrderModal({ show: false, quotation: null })
|
||||
alert.success(result.message || 'Objednávka byla vytvořena')
|
||||
navigate(`/orders/${result.data.order_id}`)
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se vytvořit objednávku')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setCreatingOrder(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.quotation) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/offers/${deleteConfirm.quotation.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, quotation: null })
|
||||
alert.success(result.message || 'Nabídka byla smazána')
|
||||
fetchData()
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat nabídku')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInvalidate = async () => {
|
||||
if (!invalidateConfirm.quotation) return
|
||||
setInvalidating(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/offers/${invalidateConfirm.quotation.id}/invalidate`, {
|
||||
method: 'POST'
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setInvalidateConfirm({ show: false, quotation: null })
|
||||
alert.success(result.message || 'Nabídka byla zneplatněna')
|
||||
fetchData()
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se zneplatnit nabídku')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setInvalidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePdf = async (quotation: Quotation) => {
|
||||
if (pdfLoading) return
|
||||
setPdfLoading(quotation.id)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/offers-pdf/${quotation.id}`)
|
||||
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 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 style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px', marginBottom: '0.5rem' }} />
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</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">Nabídky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? quotations.length} {czechPlural(pagination?.total ?? quotations.length, 'nabídka', 'nabídky', 'nabídek')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{hasPermission('offers.settings') && (
|
||||
<Link to="/offers/templates" className="admin-btn admin-btn-secondary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
Šablony
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('offers.create') && (
|
||||
<Link to="/offers/new" className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" 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á nabídka
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
|
||||
>
|
||||
<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, projektu nebo zákazníka..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{quotations.length === 0 && !draft ? (
|
||||
<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="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné nabídky.</p>
|
||||
{hasPermission('offers.create') && (
|
||||
<Link to="/offers/new" className="admin-btn admin-btn-primary">
|
||||
Vytvořit první nabídku
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('quotation_number')}>
|
||||
Číslo <SortIcon column="quotation_number" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_code')}>
|
||||
Projekt <SortIcon column="project_code" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th>Zákazník</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
|
||||
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('valid_until')}>
|
||||
Platnost <SortIcon column="valid_until" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('currency')}>
|
||||
Měna <SortIcon column="currency" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th className="text-right">Celkem</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{draft && !search && (
|
||||
<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.project_code || '—'}
|
||||
</td>
|
||||
<td>{draft.form.customer_name || '—'}</td>
|
||||
<td className="admin-mono">
|
||||
{draft.form.created_at ? formatDate(draft.form.created_at) : '—'}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{draft.form.valid_until ? formatDate(draft.form.valid_until) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-secondary">
|
||||
{draft.form.currency || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to="/offers/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>
|
||||
)}
|
||||
{(quotations as Quotation[]).map((q) => {
|
||||
const isInvalidated = q.status === 'invalidated'
|
||||
const isExpired = !isInvalidated && !q.order_id && q.valid_until && new Date(q.valid_until) < new Date(new Date().toDateString())
|
||||
return (
|
||||
<tr key={q.id} className={getRowClass(isInvalidated, !!isExpired)}>
|
||||
<td>
|
||||
<Link to={`/offers/${q.id}`} className="link-accent">
|
||||
{q.quotation_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{q.project_code || '—'}
|
||||
</td>
|
||||
<td>{q.customer_name || '—'}</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(q.created_at)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(q.valid_until)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-secondary">
|
||||
{q.currency}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono text-right fw-500">
|
||||
{formatCurrency(q.total, q.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to={`/offers/${q.id}`} className="admin-btn-icon" title={isInvalidated ? 'Zobrazit' : 'Upravit'} aria-label={isInvalidated ? 'Zobrazit' : 'Upravit'}>
|
||||
{isInvalidated ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
{!isInvalidated && hasPermission('offers.create') && (
|
||||
<button
|
||||
onClick={() => handleDuplicate(q)}
|
||||
className="admin-btn-icon"
|
||||
title="Duplikovat"
|
||||
disabled={duplicating === q.id}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!isInvalidated && q.order_id ? (
|
||||
<Link to={`/orders/${q.order_id}`} className="admin-btn-icon accent" title="Zobrazit objednávku" aria-label="Zobrazit objednávku">
|
||||
<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" />
|
||||
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">O</text>
|
||||
</svg>
|
||||
</Link>
|
||||
) : !isInvalidated && hasPermission('orders.create') && (
|
||||
<button
|
||||
onClick={() => { setCustomerOrderNumber(''); setOrderAttachment(null); setOrderModal({ show: true, quotation: q }) }}
|
||||
className="admin-btn-icon"
|
||||
title="Vytvořit objednávku"
|
||||
disabled={creatingOrder === q.id}
|
||||
>
|
||||
{creatingOrder === q.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="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isExpired && !isInvalidated && hasPermission('offers.edit') && (
|
||||
<button
|
||||
onClick={() => setInvalidateConfirm({ show: true, quotation: q })}
|
||||
className="admin-btn-icon"
|
||||
title="Zneplatnit"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{hasPermission('offers.export') && (
|
||||
<button
|
||||
onClick={() => handlePdf(q)}
|
||||
className="admin-btn-icon"
|
||||
title="PDF"
|
||||
disabled={pdfLoading === q.id}
|
||||
>
|
||||
{pdfLoading === q.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('offers.delete') && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, quotation: q })}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{quotations.length === 0 && draft && search && (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: '1.5rem' }}>
|
||||
Žádné nabídky odpovídající hledání.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat nabídku"
|
||||
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={invalidateConfirm.show}
|
||||
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleInvalidate}
|
||||
title="Zneplatnit nabídku"
|
||||
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
confirmText="Zneplatnit"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
loading={invalidating}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{orderModal.show && (
|
||||
<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={() => !creatingOrder && setOrderModal({ show: false, quotation: null })} />
|
||||
<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 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
|
||||
<p className="text-secondary" style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
||||
Nabídka: <strong>{orderModal.quotation?.quotation_number}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<FormField label="Číslo objednávky zákazníka" required>
|
||||
<input
|
||||
type="text"
|
||||
value={customerOrderNumber}
|
||||
onChange={e => setCustomerOrderNumber(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && !creatingOrder && handleCreateOrder()}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. PO-2026-001"
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příloha (PDF)">
|
||||
{orderAttachment ? (
|
||||
<div className="flex-row gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" 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 style={{ fontSize: '0.875rem' }}>
|
||||
{orderAttachment.name} <span className="text-tertiary">({(orderAttachment.size / 1024).toFixed(0)} KB)</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrderAttachment(null)}
|
||||
className="admin-btn-icon"
|
||||
title="Odebrat"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="admin-btn admin-btn-secondary admin-btn-sm" style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<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 soubor
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={e => setOrderAttachment(e.target.files?.[0] || null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<small className="admin-form-hint" style={{ marginTop: '0.25rem' }}>Max 10 MB</small>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button onClick={() => setOrderModal({ show: false, quotation: null })} className="admin-btn admin-btn-secondary" disabled={!!creatingOrder}>
|
||||
Zrušit
|
||||
</button>
|
||||
<button onClick={handleCreateOrder} className="admin-btn admin-btn-primary" disabled={!!creatingOrder || !customerOrderNumber.trim()}>
|
||||
{creatingOrder ? 'Vytváření...' : 'Vytvořit'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user