1224 lines
49 KiB
TypeScript
1224 lines
49 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, type ChangeEvent } from 'react'
|
|
import { useAlert } from '../context/AlertContext'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
|
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
|
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable'
|
|
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import ConfirmModal from '../components/ConfirmModal'
|
|
import FormField from '../components/FormField'
|
|
import Forbidden from '../components/Forbidden'
|
|
import AdminDatePicker from '../components/AdminDatePicker'
|
|
import RichEditor from '../components/RichEditor'
|
|
import useModalLock from '../hooks/useModalLock'
|
|
import useDebounce from '../hooks/useDebounce'
|
|
import apiFetch from '../utils/api'
|
|
import { formatCurrency } from '../utils/formatters'
|
|
|
|
const API_BASE = '/api/admin'
|
|
const DRAFT_KEY = 'boha_offer_draft'
|
|
|
|
interface OfferItem {
|
|
_key: string
|
|
id?: number
|
|
description: string
|
|
item_description: string
|
|
quantity: number
|
|
unit: string
|
|
unit_price: number
|
|
is_included_in_total: boolean
|
|
}
|
|
|
|
let _itemKeyCounter = 0
|
|
const nextItemKey = () => `item-${++_itemKeyCounter}`
|
|
|
|
interface ScopeSection {
|
|
title: string
|
|
title_cz: string
|
|
content: string
|
|
}
|
|
|
|
interface OfferForm {
|
|
quotation_number: string
|
|
project_code: string
|
|
customer_id: number | null
|
|
customer_name: string
|
|
created_at: string
|
|
valid_until: string
|
|
currency: string
|
|
language: string
|
|
vat_rate: number
|
|
apply_vat: boolean
|
|
exchange_rate: string
|
|
scope_title: string
|
|
scope_description: string
|
|
}
|
|
|
|
interface Customer {
|
|
id: number
|
|
name: string
|
|
city?: string
|
|
}
|
|
|
|
interface OrderInfo {
|
|
id: number
|
|
order_number: string
|
|
}
|
|
|
|
const emptyForm: OfferForm = {
|
|
quotation_number: '',
|
|
project_code: '',
|
|
customer_id: null,
|
|
customer_name: '',
|
|
created_at: new Date().toISOString().split('T')[0],
|
|
valid_until: '',
|
|
currency: 'EUR',
|
|
language: 'EN',
|
|
vat_rate: 21,
|
|
apply_vat: false,
|
|
exchange_rate: '',
|
|
scope_title: '',
|
|
scope_description: '',
|
|
}
|
|
|
|
const emptyScopeSection = (): ScopeSection => ({
|
|
title: '',
|
|
title_cz: '',
|
|
content: '',
|
|
})
|
|
|
|
const emptyItem = (): OfferItem => ({
|
|
_key: nextItemKey(),
|
|
description: '',
|
|
item_description: '',
|
|
quantity: 1,
|
|
unit: 'ks',
|
|
unit_price: 0,
|
|
is_included_in_total: true,
|
|
})
|
|
|
|
function SortableItemRow({ item, index, currency, readOnly, canDelete, onUpdate, onRemove }: {
|
|
item: OfferItem; index: number; currency: string; readOnly: boolean; canDelete: boolean;
|
|
onUpdate: (field: string, value: unknown) => void; onRemove: () => void;
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key, disabled: readOnly })
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
background: isDragging ? 'var(--bg-secondary)' : undefined,
|
|
position: 'relative' as const,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
}
|
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
|
return (
|
|
<tr ref={setNodeRef} style={style}>
|
|
{!readOnly && (
|
|
<td style={{ width: '2rem' }}>
|
|
<button type="button" className="admin-drag-handle" {...attributes} {...listeners} title="Přetáhnout">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="9" cy="5" r="1.5" /><circle cx="15" cy="5" r="1.5" />
|
|
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
|
|
<circle cx="9" cy="19" r="1.5" /><circle cx="15" cy="19" r="1.5" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
)}
|
|
<td style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>{index + 1}</td>
|
|
<td style={{ verticalAlign: 'top' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
|
<input type="text" value={item.description} onChange={e => onUpdate('description', e.target.value)}
|
|
className="admin-form-input" placeholder="Název položky" readOnly={readOnly} style={{ fontWeight: 500 }} />
|
|
<input type="text" value={item.item_description} onChange={e => onUpdate('item_description', e.target.value)}
|
|
className="admin-form-input" placeholder="Podrobný popis (volitelný)" readOnly={readOnly} style={{ fontSize: '0.8rem', opacity: 0.8 }} />
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<input type="number" value={item.quantity} onChange={e => onUpdate('quantity', parseFloat(e.target.value) || 0)}
|
|
className="admin-form-input" step="1" readOnly={readOnly} />
|
|
</td>
|
|
<td>
|
|
<input type="text" value={item.unit} onChange={e => onUpdate('unit', e.target.value)}
|
|
className="admin-form-input" readOnly={readOnly} />
|
|
</td>
|
|
<td>
|
|
<input type="number" value={item.unit_price} onChange={e => onUpdate('unit_price', parseFloat(e.target.value) || 0)}
|
|
className="admin-form-input" step="0.01" readOnly={readOnly} />
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<input type="checkbox" checked={item.is_included_in_total}
|
|
onChange={e => onUpdate('is_included_in_total', e.target.checked)} disabled={readOnly} />
|
|
</td>
|
|
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>
|
|
{formatCurrency(lineTotal, currency)}
|
|
</td>
|
|
{!readOnly && (
|
|
<td>
|
|
<button onClick={onRemove} className="admin-btn-icon danger" title="Odebrat" disabled={!canDelete}>
|
|
<svg width="16" height="16" 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>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
export default function OfferDetail() {
|
|
const { id } = useParams()
|
|
const isEdit = Boolean(id)
|
|
const alert = useAlert()
|
|
const { hasPermission } = useAuth()
|
|
const navigate = useNavigate()
|
|
const dndSensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
|
useSensor(KeyboardSensor),
|
|
)
|
|
|
|
const [loading, setLoading] = useState(isEdit)
|
|
const [saving, setSaving] = useState(false)
|
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
|
|
const [form, setForm] = useState<OfferForm>(emptyForm)
|
|
const [items, setItems] = useState<OfferItem[]>([emptyItem()])
|
|
const [sections, setSections] = useState<ScopeSection[]>([])
|
|
const [scopeTemplates, setScopeTemplates] = useState<Array<{ id: number; name: string; description?: string; scope_template_sections?: Array<{ title?: string; title_cz?: string; content?: string }> }>>([])
|
|
const [customers, setCustomers] = useState<Customer[]>([])
|
|
const [customerSearch, setCustomerSearch] = useState('')
|
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
|
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null)
|
|
const [offerStatus, setOfferStatus] = useState<string>('')
|
|
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [creatingOrder, setCreatingOrder] = useState(false)
|
|
const [showOrderModal, setShowOrderModal] = useState(false)
|
|
const [invalidateConfirm, setInvalidateConfirm] = useState(false)
|
|
const [invalidatingOffer, setInvalidatingOffer] = useState(false)
|
|
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
|
|
const [pdfLoading, setPdfLoading] = useState(false)
|
|
const [lockedBy, setLockedBy] = useState<{ user_id: number; username: string; full_name: string } | null>(null)
|
|
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
useModalLock(showOrderModal)
|
|
|
|
const isInvalidated = offerStatus === 'invalidated'
|
|
const isLockedByOther = !!lockedBy
|
|
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
|
|
|
|
// Load data
|
|
const fetchDetail = useCallback(async () => {
|
|
if (!id) return
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/offers/${id}`)
|
|
if (response.status === 401) return
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
const d = result.data
|
|
setForm({
|
|
quotation_number: d.quotation_number || '',
|
|
project_code: d.project_code || '',
|
|
customer_id: d.customer_id || null,
|
|
customer_name: d.customer_name || '',
|
|
created_at: d.created_at ? String(d.created_at).substring(0, 10) : '',
|
|
valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : '',
|
|
currency: d.currency || 'EUR',
|
|
language: d.language || 'EN',
|
|
vat_rate: d.vat_rate ?? 21,
|
|
apply_vat: !!d.apply_vat,
|
|
exchange_rate: d.exchange_rate || '',
|
|
scope_title: d.scope_title || '',
|
|
scope_description: d.scope_description || '',
|
|
})
|
|
setItems(d.items?.length ? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) : [emptyItem()])
|
|
setSections(d.sections?.length ? d.sections.map((s: any) => ({
|
|
title: s.title || '',
|
|
title_cz: s.title_cz || '',
|
|
content: s.content || '',
|
|
})) : [])
|
|
setOfferStatus(d.status || '')
|
|
setOrderInfo(d.order || null)
|
|
setLockedBy(d.locked_by || null)
|
|
|
|
// Try to acquire lock if not locked by someone else and not invalidated
|
|
if (!d.locked_by && d.status !== 'invalidated' && hasPermission('offers.edit')) {
|
|
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: 'POST' }).catch(() => {})
|
|
}
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se načíst nabídku')
|
|
navigate('/offers')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
navigate('/offers')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [id, alert, navigate, hasPermission])
|
|
|
|
// Heartbeat to keep lock alive + cleanup on unmount
|
|
useEffect(() => {
|
|
if (!isEdit || !id || isLockedByOther || isInvalidated) return
|
|
|
|
heartbeatRef.current = setInterval(() => {
|
|
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: 'POST' }).catch(() => {})
|
|
}, 2 * 60 * 1000) // every 2 minutes
|
|
|
|
return () => {
|
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current)
|
|
// Release lock on unmount
|
|
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: 'POST' }).catch(() => {})
|
|
}
|
|
}, [isEdit, id, isLockedByOther, isInvalidated])
|
|
|
|
useEffect(() => {
|
|
if (isEdit) fetchDetail()
|
|
}, [isEdit, fetchDetail])
|
|
|
|
useEffect(() => {
|
|
const loadCustomers = async () => {
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/customers`)
|
|
if (res.status === 401) return
|
|
const data = await res.json()
|
|
if (data.success) setCustomers(Array.isArray(data.data) ? data.data : data.data?.customers || [])
|
|
} catch { /* silent */ }
|
|
}
|
|
const loadScopeTemplates = async () => {
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/offers-templates`)
|
|
if (res.status === 401) return
|
|
const data = await res.json()
|
|
if (data.success && Array.isArray(data.data)) {
|
|
setScopeTemplates(data.data)
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
loadCustomers()
|
|
loadScopeTemplates()
|
|
}, [])
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = () => setShowCustomerDropdown(false)
|
|
if (showCustomerDropdown) {
|
|
document.addEventListener('click', handleClickOutside)
|
|
return () => document.removeEventListener('click', handleClickOutside)
|
|
}
|
|
}, [showCustomerDropdown])
|
|
|
|
// Fetch next quotation number for new offers
|
|
useEffect(() => {
|
|
if (isEdit) return
|
|
const fetchNextNumber = async () => {
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/offers/next-number`)
|
|
if (res.status === 401) return
|
|
const data = await res.json()
|
|
if (data.success) {
|
|
setForm(prev => ({ ...prev, quotation_number: data.data?.next_number || data.data?.number || '' }))
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
fetchNextNumber()
|
|
}, [isEdit])
|
|
|
|
// Restore draft from localStorage on mount (create mode only)
|
|
const draftRestoredRef = useRef(false)
|
|
useEffect(() => {
|
|
if (isEdit || draftRestoredRef.current) return
|
|
draftRestoredRef.current = true
|
|
try {
|
|
const raw = localStorage.getItem(DRAFT_KEY)
|
|
if (!raw) return
|
|
const draft = JSON.parse(raw)
|
|
if (draft && draft.form) {
|
|
setForm(prev => ({
|
|
...prev,
|
|
project_code: draft.form.project_code || prev.project_code,
|
|
customer_name: draft.form.customer_name || prev.customer_name,
|
|
created_at: draft.form.created_at || prev.created_at,
|
|
valid_until: draft.form.valid_until || prev.valid_until,
|
|
currency: draft.form.currency || prev.currency,
|
|
}))
|
|
if (draft.form.customer_id) {
|
|
setForm(prev => ({ ...prev, customer_id: draft.form.customer_id }))
|
|
}
|
|
}
|
|
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
|
|
setItems(draft.items)
|
|
}
|
|
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
|
|
setSections(draft.sections)
|
|
}
|
|
} catch { /* ignore corrupt data */ }
|
|
}, [isEdit])
|
|
|
|
// Auto-save draft to localStorage (create mode only)
|
|
const draftPayload = JSON.stringify({ form, items, sections })
|
|
const debouncedDraft = useDebounce(draftPayload, 1500)
|
|
useEffect(() => {
|
|
if (isEdit) return
|
|
try {
|
|
const draft = {
|
|
form: {
|
|
project_code: form.project_code,
|
|
customer_id: form.customer_id,
|
|
customer_name: form.customer_name,
|
|
created_at: form.created_at,
|
|
valid_until: form.valid_until,
|
|
currency: form.currency,
|
|
},
|
|
items,
|
|
sections,
|
|
savedAt: new Date().toISOString(),
|
|
}
|
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
|
|
} catch { /* localStorage full or unavailable */ }
|
|
}, [debouncedDraft]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const updateForm = (field: keyof OfferForm, value: unknown) => {
|
|
setForm(prev => ({ ...prev, [field]: value }))
|
|
setErrors(prev => ({ ...prev, [field]: undefined }))
|
|
}
|
|
|
|
const selectCustomer = (c: Customer) => {
|
|
setForm(prev => ({ ...prev, customer_id: c.id, customer_name: c.name }))
|
|
setErrors(prev => ({ ...prev, customer_id: undefined }))
|
|
setCustomerSearch('')
|
|
setShowCustomerDropdown(false)
|
|
}
|
|
|
|
const clearCustomer = () => {
|
|
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
|
|
}
|
|
|
|
const updateItem = (index: number, field: keyof OfferItem, value: unknown) => {
|
|
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
|
|
}
|
|
|
|
const addItem = () => setItems(prev => [...prev, emptyItem()])
|
|
|
|
const removeItem = (index: number) => {
|
|
setItems(prev => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
// Totals
|
|
const subtotal = items.reduce((sum, item) => {
|
|
if (item.is_included_in_total) {
|
|
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
|
}
|
|
return sum
|
|
}, 0)
|
|
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0
|
|
const total = subtotal + vatAmount
|
|
|
|
const filteredCustomers = customerSearch
|
|
? customers.filter(c => c.name.toLowerCase().includes(customerSearch.toLowerCase()))
|
|
: customers
|
|
|
|
const handleSave = async () => {
|
|
const newErrors: Record<string, string> = {}
|
|
if (!form.created_at) newErrors.created_at = 'Datum je povinné'
|
|
if (!form.valid_until) newErrors.valid_until = 'Platnost je povinná'
|
|
if (items.length === 0) newErrors.items = 'Přidejte alespoň jednu položku'
|
|
setErrors(newErrors)
|
|
if (Object.keys(newErrors).length > 0) return
|
|
|
|
setSaving(true)
|
|
try {
|
|
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`
|
|
const response = await apiFetch(url, {
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...form, items: items.map((item, i) => ({ ...item, position: i })), sections: sections.map((s, i) => ({ ...s, position: i })) })
|
|
})
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message || (isEdit ? 'Nabídka byla aktualizována' : 'Nabídka byla vytvořena'))
|
|
if (!isEdit) {
|
|
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
|
}
|
|
if (!isEdit && result.data?.id) {
|
|
navigate(`/offers/${result.data.id}`)
|
|
}
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se uložit nabídku')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateOrder = async () => {
|
|
if (!customerOrderNumber.trim()) {
|
|
alert.error('Číslo objednávky zákazníka je povinné')
|
|
return
|
|
}
|
|
setCreatingOrder(true)
|
|
try {
|
|
let fetchOptions: RequestInit
|
|
if (orderAttachment) {
|
|
// With attachment: send as multipart/form-data
|
|
const formData = new FormData()
|
|
formData.append('quotationId', String(id))
|
|
formData.append('customerOrderNumber', customerOrderNumber.trim())
|
|
formData.append('attachment', orderAttachment)
|
|
fetchOptions = { method: 'POST', body: formData }
|
|
} else {
|
|
// Without attachment: send as JSON (avoids multipart content-type issues)
|
|
fetchOptions = {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ quotationId: id, customerOrderNumber: customerOrderNumber.trim() }),
|
|
}
|
|
}
|
|
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions)
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
setShowOrderModal(false)
|
|
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(false)
|
|
}
|
|
}
|
|
|
|
const handleInvalidateOffer = async () => {
|
|
setInvalidatingOffer(true)
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/offers/${id}/invalidate`, {
|
|
method: 'POST'
|
|
})
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
setInvalidateConfirm(false)
|
|
setOfferStatus('invalidated')
|
|
alert.success(result.message || 'Nabídka byla zneplatněna')
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se zneplatnit nabídku')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setInvalidatingOffer(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
setDeleting(true)
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/offers/${id}`, { method: 'DELETE' })
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message || 'Nabídka byla smazána')
|
|
navigate('/offers')
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se smazat nabídku')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setDeleting(false)
|
|
setDeleteConfirm(false)
|
|
}
|
|
}
|
|
|
|
const handlePdf = async () => {
|
|
if (!isEdit || pdfLoading) return
|
|
setPdfLoading(true)
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/offers-pdf/${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(false)
|
|
}
|
|
}
|
|
|
|
const getRequiredPerm = () => {
|
|
if (!isEdit) return 'offers.create'
|
|
return isInvalidated ? 'offers.view' : 'offers.edit'
|
|
}
|
|
const requiredPerm = getRequiredPerm()
|
|
if (!hasPermission(requiredPerm)) return <Forbidden />
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
|
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
|
<div className="flex-row-gap">
|
|
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
|
|
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
|
</div>
|
|
<div className="admin-skeleton-row gap-2">
|
|
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
|
|
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
|
|
</div>
|
|
</div>
|
|
<div className="admin-card">
|
|
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
|
{[0, 1, 2, 3].map(i => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
<div className="admin-skeleton-line w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<motion.div
|
|
className="admin-page-header"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
>
|
|
<div className="flex-row gap-4">
|
|
<Link to="/offers" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
</svg>
|
|
</Link>
|
|
<div>
|
|
<h1 className="admin-page-title">
|
|
{isEdit ? `Nabídka ${form.quotation_number}` : 'Nová nabídka'}
|
|
{isInvalidated && (
|
|
<span className="admin-badge admin-badge-danger" style={{ marginLeft: '0.75rem', verticalAlign: 'middle', fontSize: '0.75rem' }}>
|
|
Zneplatněna
|
|
</span>
|
|
)}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<div className="admin-page-actions">
|
|
{isEdit && hasPermission('offers.export') && (
|
|
<button onClick={handlePdf} className="admin-btn admin-btn-secondary" disabled={pdfLoading}>
|
|
{pdfLoading ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
PDF...
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
PDF
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
{isEdit && !isInvalidated && hasPermission('orders.create') && !orderInfo && (
|
|
<button onClick={() => { setCustomerOrderNumber(''); setOrderAttachment(null); setShowOrderModal(true) }} className="admin-btn admin-btn-secondary">
|
|
Vytvořit objednávku
|
|
</button>
|
|
)}
|
|
{isEdit && orderInfo && (
|
|
<Link to={`/orders/${orderInfo.id}`} className="admin-btn admin-btn-secondary">
|
|
Objednávka {orderInfo.order_number}
|
|
</Link>
|
|
)}
|
|
{isExpiredNotInvalidated && hasPermission('offers.edit') && (
|
|
<button onClick={() => setInvalidateConfirm(true)} className="admin-btn admin-btn-secondary">
|
|
Zneplatnit
|
|
</button>
|
|
)}
|
|
{!isInvalidated && !isLockedByOther && (
|
|
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Ukládání...
|
|
</>
|
|
) : 'Uložit'}
|
|
</button>
|
|
)}
|
|
{isEdit && hasPermission('offers.delete') && (
|
|
<button onClick={() => setDeleteConfirm(true)} className="admin-btn admin-btn-primary">
|
|
Smazat
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Lock banner */}
|
|
{isLockedByOther && (
|
|
<div style={{
|
|
background: 'color-mix(in srgb, var(--warning) 15%, transparent)',
|
|
border: '1px solid var(--warning)',
|
|
borderRadius: 'var(--border-radius-sm)',
|
|
padding: '0.75rem 1rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
marginBottom: '1rem',
|
|
fontSize: '0.875rem',
|
|
color: 'var(--warning)',
|
|
}}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
<span>Nabídku právě upravuje <strong>{lockedBy!.full_name}</strong>. Můžete ji pouze prohlížet.</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quotation Form */}
|
|
<motion.div
|
|
className={`offers-editor-section${(isInvalidated || isLockedByOther) ? ' offers-readonly' : ''}`}
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.06 }}
|
|
>
|
|
<h3 className="admin-card-title">Základní údaje</h3>
|
|
<div className="admin-form">
|
|
<div className="offers-form-row-3">
|
|
<FormField label="Číslo nabídky">
|
|
<input
|
|
type="text"
|
|
value={form.quotation_number}
|
|
onChange={e => setForm(prev => ({ ...prev, quotation_number: e.target.value }))}
|
|
className="admin-form-input"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Kód projektu">
|
|
<input
|
|
type="text"
|
|
value={form.project_code}
|
|
onChange={(e) => updateForm('project_code', e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="Volitelný kód projektu"
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Zákazník" error={errors.customer_id}>
|
|
{form.customer_id ? (
|
|
<div className="offers-customer-selected">
|
|
<span>{form.customer_name}</span>
|
|
{!isInvalidated && !isLockedByOther && (
|
|
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka">
|
|
<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="offers-customer-select" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="text"
|
|
value={customerSearch}
|
|
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
|
|
onFocus={() => setShowCustomerDropdown(true)}
|
|
className="admin-form-input"
|
|
placeholder="Hledat zákazníka..."
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
{showCustomerDropdown && !isInvalidated && (
|
|
<div className="offers-customer-dropdown">
|
|
{filteredCustomers.length === 0 ? (
|
|
<div className="offers-customer-dropdown-empty">Žádní zákazníci</div>
|
|
) : (
|
|
filteredCustomers.slice(0, 20).map(c => (
|
|
<div key={c.id} className="offers-customer-dropdown-item" onMouseDown={() => selectCustomer(c)}>
|
|
<div>{c.name}</div>
|
|
{c.city && <div>{c.city}</div>}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="admin-form-row">
|
|
<FormField label="Datum vytvoření" error={errors.created_at} required>
|
|
{(isInvalidated || isLockedByOther) ? (
|
|
<input type="text" value={form.created_at} className="admin-form-input" readOnly />
|
|
) : (
|
|
<AdminDatePicker
|
|
mode="date"
|
|
value={form.created_at}
|
|
onChange={(val: string) => {
|
|
updateForm('created_at', val)
|
|
setErrors(prev => ({ ...prev, created_at: undefined }))
|
|
}}
|
|
/>
|
|
)}
|
|
</FormField>
|
|
<FormField label="Platnost do" error={errors.valid_until} required>
|
|
{(isInvalidated || isLockedByOther) ? (
|
|
<input type="text" value={form.valid_until} className="admin-form-input" readOnly />
|
|
) : (
|
|
<AdminDatePicker
|
|
mode="date"
|
|
value={form.valid_until}
|
|
onChange={(val: string) => {
|
|
updateForm('valid_until', val)
|
|
setErrors(prev => ({ ...prev, valid_until: undefined }))
|
|
}}
|
|
/>
|
|
)}
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="admin-form-row">
|
|
<FormField label="Měna">
|
|
<select
|
|
value={form.currency}
|
|
onChange={(e) => updateForm('currency', e.target.value)}
|
|
className="admin-form-select"
|
|
disabled={isInvalidated || isLockedByOther}
|
|
>
|
|
<option value="EUR">EUR</option>
|
|
<option value="USD">USD</option>
|
|
<option value="CZK">CZK</option>
|
|
<option value="GBP">GBP</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Jazyk nabídky">
|
|
<select
|
|
value={form.language}
|
|
onChange={(e) => updateForm('language', e.target.value)}
|
|
className="admin-form-select"
|
|
disabled={isInvalidated || isLockedByOther}
|
|
>
|
|
<option value="EN">English</option>
|
|
<option value="CZ">Čeština</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="offers-form-row-3">
|
|
<FormField label="Sazba DPH (%)">
|
|
<div className="flex-row-gap">
|
|
<input
|
|
type="number"
|
|
value={form.vat_rate}
|
|
onChange={(e) => updateForm('vat_rate', parseFloat(e.target.value) || 0)}
|
|
className="admin-form-input flex-1"
|
|
step="0.1"
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.apply_vat}
|
|
onChange={(e) => updateForm('apply_vat', e.target.checked)}
|
|
disabled={isInvalidated || isLockedByOther}
|
|
/>
|
|
<span>Účtovat DPH</span>
|
|
</label>
|
|
</div>
|
|
</FormField>
|
|
<FormField label="Směnný kurz">
|
|
<input
|
|
type="number"
|
|
value={form.exchange_rate}
|
|
onChange={(e) => updateForm('exchange_rate', e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="Volitelný"
|
|
step="0.0001"
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Items Section with drag-and-drop */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.12 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<div className="admin-card-header flex-between">
|
|
<h3 className="admin-card-title">Položky</h3>
|
|
{!isInvalidated && !isLockedByOther && (
|
|
<button onClick={addItem} className="admin-btn admin-btn-secondary admin-btn-sm">
|
|
+ Přidat položku
|
|
</button>
|
|
)}
|
|
</div>
|
|
{errors.items && <p style={{ color: 'var(--color-danger)', fontSize: '0.85rem' }}>{errors.items}</p>}
|
|
|
|
<div className="admin-table-responsive">
|
|
<DndContext
|
|
sensors={dndSensors}
|
|
collisionDetection={closestCenter}
|
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
|
onDragEnd={(event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
setItems(prev => {
|
|
const oldIndex = prev.findIndex(i => i._key === String(active.id))
|
|
const newIndex = prev.findIndex(i => i._key === String(over.id))
|
|
if (oldIndex === -1 || newIndex === -1) return prev
|
|
return arrayMove(prev, oldIndex, newIndex)
|
|
})
|
|
}}
|
|
>
|
|
<SortableContext items={items.map(i => i._key)} strategy={verticalListSortingStrategy}>
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
{!isInvalidated && !isLockedByOther && <th style={{ width: '2rem' }} />}
|
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
|
<th>Popis</th>
|
|
<th style={{ width: '5rem' }}>Množství</th>
|
|
<th style={{ width: '5rem' }}>Jednotka</th>
|
|
<th style={{ width: '7rem' }}>Cena/ks</th>
|
|
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
|
|
<th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th>
|
|
{!isInvalidated && !isLockedByOther && <th style={{ width: '3rem' }} />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item, index) => (
|
|
<SortableItemRow
|
|
key={item._key}
|
|
item={item}
|
|
index={index}
|
|
currency={form.currency}
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
canDelete={items.length > 1}
|
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
|
onRemove={() => removeItem(index)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* Totals */}
|
|
<div className="offers-totals-summary">
|
|
<div className="offers-totals-row">
|
|
<span>Mezisoučet:</span>
|
|
<span>{formatCurrency(subtotal, form.currency)}</span>
|
|
</div>
|
|
{form.apply_vat && (
|
|
<div className="offers-totals-row">
|
|
<span>DPH ({form.vat_rate}%):</span>
|
|
<span>{formatCurrency(vatAmount, form.currency)}</span>
|
|
</div>
|
|
)}
|
|
<div className="offers-totals-row offers-totals-total">
|
|
<span>Celkem:</span>
|
|
<span>{formatCurrency(total, form.currency)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Scope/Range Section */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.15 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<div className="admin-card-header flex-between">
|
|
<h3 className="admin-card-title">Rozsah projektu</h3>
|
|
{!isInvalidated && !isLockedByOther && (
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
{scopeTemplates.length > 0 && (
|
|
<select
|
|
className="admin-form-select"
|
|
style={{ width: 'auto', minWidth: '160px' }}
|
|
defaultValue=""
|
|
onChange={(e) => {
|
|
const templateId = Number(e.target.value)
|
|
if (!templateId) return
|
|
const template = scopeTemplates.find(t => t.id === templateId)
|
|
if (template?.scope_template_sections?.length) {
|
|
const newSections = template.scope_template_sections.map((s: any) => ({
|
|
title: s.title || '',
|
|
title_cz: s.title_cz || '',
|
|
content: s.content || '',
|
|
}))
|
|
setSections(prev => [...prev, ...newSections])
|
|
if (template.description) {
|
|
setForm(prev => ({ ...prev, scope_description: template.description || prev.scope_description }))
|
|
}
|
|
alert.success(`Načtena šablona "${template.name}"`)
|
|
}
|
|
e.target.value = ''
|
|
}}
|
|
>
|
|
<option value="">Ze šablony...</option>
|
|
{scopeTemplates.map(t => (
|
|
<option key={t.id} value={t.id}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<button
|
|
onClick={() => setSections(prev => [...prev, emptyScopeSection()])}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
>
|
|
+ Přidat sekci
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{sections.length === 0 ? (
|
|
<div className="admin-empty-state" style={{ padding: '2rem' }}>
|
|
<p style={{ color: 'var(--text-tertiary)' }}>Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', marginTop: '1rem' }}>
|
|
{sections.map((section, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
border: '1px solid var(--border-primary)',
|
|
borderRadius: '8px',
|
|
padding: '1rem',
|
|
background: 'var(--bg-secondary)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
|
Sekce {idx + 1}
|
|
{(form.language === 'CZ' ? section.title_cz : section.title) && (
|
|
<span style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
|
|
— {form.language === 'CZ' ? (section.title_cz || section.title) : section.title}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{!isInvalidated && !isLockedByOther && (
|
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
|
{idx > 0 && (
|
|
<button
|
|
onClick={() => setSections(prev => {
|
|
const arr = [...prev]
|
|
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
|
|
return arr
|
|
})}
|
|
className="admin-btn-icon"
|
|
title="Posunout nahoru"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="18 15 12 9 6 15" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{idx < sections.length - 1 && (
|
|
<button
|
|
onClick={() => setSections(prev => {
|
|
const arr = [...prev]
|
|
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
|
|
return arr
|
|
})}
|
|
className="admin-btn-icon"
|
|
title="Posunout dolů"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setSections(prev => prev.filter((_, i) => i !== idx))}
|
|
className="admin-btn-icon danger"
|
|
title="Odebrat sekci"
|
|
>
|
|
<svg width="16" height="16" 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>
|
|
|
|
<div className="admin-form-row">
|
|
<FormField label={<><span className="offers-lang-badge">EN</span>Název sekce</>}>
|
|
<input
|
|
type="text"
|
|
value={section.title}
|
|
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title: e.target.value } : s))}
|
|
className="admin-form-input"
|
|
placeholder="Název sekce (anglicky)"
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
</FormField>
|
|
<FormField label={<><span className="offers-lang-badge offers-lang-badge-cz">CZ</span>Název sekce</>}>
|
|
<input
|
|
type="text"
|
|
value={section.title_cz}
|
|
onChange={(e) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, title_cz: e.target.value } : s))}
|
|
className="admin-form-input"
|
|
placeholder="Název sekce (česky)"
|
|
readOnly={isInvalidated || isLockedByOther}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
<label className="admin-form-label">Obsah</label>
|
|
{(isInvalidated || isLockedByOther) ? (
|
|
<div className="rich-editor">
|
|
<div
|
|
className="ql-editor"
|
|
style={{ minHeight: '80px', background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: 'var(--border-radius-sm)', cursor: 'default', overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
|
dangerouslySetInnerHTML={{ __html: section.content || '<em style="color: var(--text-tertiary)">Prázdný obsah</em>' }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<RichEditor
|
|
value={section.content}
|
|
onChange={(val) => setSections(prev => prev.map((s, i) => i === idx ? { ...s, content: val } : s))}
|
|
placeholder="Obsah sekce..."
|
|
minHeight="120px"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Order modal */}
|
|
<AnimatePresence>
|
|
{showOrderModal && (
|
|
<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 && setShowOrderModal(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 }}
|
|
>
|
|
<div className="admin-modal-header">
|
|
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
|
|
</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: ChangeEvent<HTMLInputElement>) => 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">
|
|
<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' }}>
|
|
Vybrat soubor
|
|
<input
|
|
type="file"
|
|
accept="application/pdf"
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => 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={() => setShowOrderModal(false)} 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>
|
|
|
|
<ConfirmModal
|
|
isOpen={invalidateConfirm}
|
|
onClose={() => setInvalidateConfirm(false)}
|
|
onConfirm={handleInvalidateOffer}
|
|
title="Zneplatnit nabídku"
|
|
message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
|
confirmText="Zneplatnit"
|
|
cancelText="Zrušit"
|
|
type="danger"
|
|
loading={invalidatingOffer}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
isOpen={deleteConfirm}
|
|
onClose={() => setDeleteConfirm(false)}
|
|
onConfirm={handleDelete}
|
|
title="Smazat nabídku"
|
|
message={`Opravdu chcete smazat nabídku "${form.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}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|