Files
app/src/admin/pages/OrderDetail.jsx
2026-03-12 12:43:56 +01:00

610 lines
24 KiB
JavaScript

import { useState, useEffect, useMemo } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
const TRANSITION_LABELS = {
v_realizaci: 'Zahájit realizaci',
dokoncena: 'Dokončit'
}
const TRANSITION_CLASSES = {
v_realizaci: 'admin-btn admin-btn-primary',
dokoncena: 'admin-btn admin-btn-primary'
}
export default function OrderDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [order, setOrder] = useState(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState(null)
const [statusConfirm, setStatusConfirm] = useState({ show: false, status: null })
const [editingNumber, setEditingNumber] = useState(false)
const [orderNumber, setOrderNumber] = useState('')
const [savingNumber, setSavingNumber] = useState(false)
const [attachmentLoading, setAttachmentLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/orders.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setOrder(result.data)
setNotes(result.data.notes || '')
} else {
alert.error(result.error || 'Nepodařilo se načíst objednávku')
navigate('/orders')
}
} catch {
alert.error('Chyba připojení')
navigate('/orders')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchDetail()
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }
const subtotal = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
}
return sum
}, 0)
const vatAmount = Number(order.apply_vat) ? subtotal * ((Number(order.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [order])
if (!hasPermission('orders.view')) return <Forbidden />
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
setStatusConfirm({ show: false, status: null })
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: statusConfirm.status })
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Stav byl změněn')
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
} finally {
setStatusChanging(null)
}
}
const handleStartEditNumber = () => {
setOrderNumber(order.order_number)
setEditingNumber(true)
}
const handleSaveNumber = async () => {
const trimmed = orderNumber.trim()
if (!trimmed) return
if (trimmed === order.order_number) {
setEditingNumber(false)
return
}
setSavingNumber(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_number: trimmed })
})
const result = await response.json()
if (result.success) {
alert.success('Číslo objednávky bylo změněno')
setEditingNumber(false)
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit číslo')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSavingNumber(false)
}
}
const handleSaveNotes = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notes })
})
const result = await response.json()
if (result.success) {
alert.success('Poznámky byly uloženy')
} else {
alert.error(result.error || 'Nepodařilo se uložit poznámky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleViewAttachment = async () => {
const newWindow = window.open('', '_blank')
setAttachmentLoading(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?action=attachment&id=${id}`)
if (!response.ok) {
newWindow.close()
alert.error('Nepodařilo se stáhnout přílohu')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
newWindow.location.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow.close()
alert.error('Chyba připojení')
} finally {
setAttachmentLoading(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Objednávka byla smazána')
navigate('/orders')
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteConfirm(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<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" style={{ gap: '0.5rem' }}>
<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 className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-full" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-3/4" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-1/2" /></div>
</div>
))}
</div>
</div>
</div>
)
}
if (!order) return null
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/orders" 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" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{editingNumber ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka
<input
type="text"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveNumber()
if (e.key === 'Escape') setEditingNumber(false)
}}
className="admin-form-input"
style={{ width: '10rem', fontSize: '1rem', padding: '0.25rem 0.5rem', height: 'auto' }}
autoFocus
disabled={savingNumber}
/>
<button onClick={handleSaveNumber} className="admin-btn-icon" title="Uložit" aria-label="Uložit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button onClick={() => setEditingNumber(false)} className="admin-btn-icon" title="Zrušit" aria-label="Zrušit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka {order.order_number}
{hasPermission('orders.edit') && (
<button onClick={handleStartEditNumber} className="admin-btn-icon" title="Změnit číslo" aria-label="Změnit číslo" style={{ opacity: 0.5 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
</span>
)}
<span className={`admin-badge ${STATUS_CLASSES[order.status] || ''}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</h1>
</div>
</div>
<div className="admin-page-actions">
{order.invoice ? (
<Link to={`/invoices/${order.invoice.id}`} className="admin-btn admin-btn-secondary">
<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>
Faktura {order.invoice.invoice_number}
</Link>
) : (
hasPermission('invoices.create') && order.status === 'dokoncena' && (
<Link to={`/invoices/new?fromOrder=${order.id}`} className="admin-btn admin-btn-secondary">
<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>
Vytvořit fakturu
</Link>
)
)}
{hasPermission('orders.edit') && order.valid_transitions?.filter(s => s !== 'stornovana').length > 0 && (
order.valid_transitions.filter(s => s !== 'stornovana').map(status => (
<button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
className={TRANSITION_CLASSES[status] || 'admin-btn admin-btn-secondary'}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
TRANSITION_LABELS[status] || status
)}
</button>
))
)}
{hasPermission('orders.delete') && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
</motion.div>
{/* Info card */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form-row" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Nabídka</label>
<div>
<Link to={`/offers/${order.quotation_id}`} className="link-accent">
{order.quotation_number}
</Link>
{order.project_code && (
<span className="text-tertiary" style={{ marginLeft: '0.5rem' }}>({order.project_code})</span>
)}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Projekt</label>
<div>
{order.project ? (
<Link to={`/projects/${order.project.id}`} className="link-accent">
{order.project.project_number} {order.project.name}
</Link>
) : '—'}
</div>
</div>
</div>
<div className="admin-form-row admin-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div style={{ fontWeight: 500 }}>{order.customer_name || '—'}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Číslo obj. zákazníka</label>
<div>{order.customer_order_number || '—'}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<div>{order.currency}</div>
</div>
</div>
<div className="admin-form-row admin-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Datum vytvoření</label>
<div>{formatDate(order.created_at)}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příloha</label>
<div>
{order.attachment_name ? (
<button
onClick={handleViewAttachment}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}
disabled={attachmentLoading}
>
{attachmentLoading ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
<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>
)}
{order.attachment_name}
</button>
) : '—'}
</div>
</div>
</div>
</div>
</motion.div>
{/* Items (read-only) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{order.items?.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '8rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Jedn. cena</th>
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '9rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Celkem</th>
</tr>
</thead>
<tbody>
{order.items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item.id || index}>
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<div style={{ fontWeight: 500 }}>{item.description || '—'}</div>
{item.item_description && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-tertiary)', marginTop: '0.25rem' }}>{item.item_description}</div>
)}
</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '—'}</td>
<td className="admin-mono" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>{formatCurrency(item.unit_price, order.currency)}</td>
<td style={{ textAlign: 'center' }}>{Number(item.is_included_in_total) ? 'Ano' : 'Ne'}</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>{formatCurrency(lineTotal, order.currency)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p style={{ color: 'var(--text-tertiary)' }}>Žádné položky.</p>
)}
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, order.currency)}</span>
</div>
{Number(order.apply_vat) > 0 && (
<div className="offers-totals-row">
<span>DPH ({order.vat_rate}%):</span>
<span>{formatCurrency(totals.vatAmount, order.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, order.currency)}</span>
</div>
</div>
</div>
</motion.div>
{/* Sections (read-only) */}
{order.sections?.length > 0 && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Rozsah projektu</h3>
{order.scope_title && (
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{order.scope_title}</div>
)}
{order.scope_description && (
<div style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>{order.scope_description}</div>
)}
<div className="offers-scope-list">
{order.sections.map((section, index) => (
<div key={section.id || index} className="offers-scope-section" style={{ cursor: 'default' }}>
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{(order.language === 'CZ' ? (section.title_cz || section.title) : (section.title || section.title_cz)) || `Sekce ${index + 1}`}</span>
</div>
{section.content && (
<div
className="offers-scope-content rich-text-view"
style={{ padding: '1rem' }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(section.content) }}
/>
)}
</div>
))}
</div>
</div>
</motion.div>
)}
{/* Notes (editable) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<div className="admin-form-group">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="admin-form-input"
rows={4}
placeholder="Interní poznámky k objednávce..."
disabled={!hasPermission('orders.edit')}
/>
</div>
{hasPermission('orders.edit') && (
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={saving}
>
{saving ? 'Ukládání...' : 'Uložit poznámky'}
</button>
</div>
)}
</div>
</motion.div>
{/* Status change confirmation */}
<ConfirmModal
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${STATUS_LABELS[statusConfirm.status]}"?${statusConfirm.status === 'dokoncena' ? ' Projekt bude automaticky dokončen.' : ''}`}
confirmText={TRANSITION_LABELS[statusConfirm.status] || 'Potvrdit'}
cancelText="Zrušit"
type="default"
/>
{/* Delete confirmation */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}