refactor: P3 dekompozice velkych komponent

Dashboard.jsx (1346 -> 378 LOC):
- DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions
- dashboardHelpers.js (konstanty + helper funkce)

OfferDetail.jsx (1061 -> ~530 LOC):
- useOfferForm hook (form state, draft, items/sections, submit)
- OfferCustomerPicker (customer search/select dropdown)

AttendanceAdmin.jsx (1036 -> ~275 LOC):
- useAttendanceAdmin hook (data fetching, filters, CRUD, print)
- AttendanceShiftTable (shift records table)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:22:38 +01:00
parent 6863c7c557
commit df506dfea4
14 changed files with 2558 additions and 2297 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
@@ -9,28 +9,12 @@ import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import OfferItemsSection from '../components/OfferItemsSection'
import OfferScopeSection from '../components/OfferScopeSection'
import OfferCustomerPicker from '../components/OfferCustomerPicker'
import useModalLock from '../hooks/useModalLock'
import useOfferForm from '../hooks/useOfferForm'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
let _keyCounter = 0
const emptyItem = () => ({
_key: `item-${++_keyCounter}`,
description: '',
item_description: '',
quantity: 1,
unit: '',
unit_price: 0,
is_included_in_total: true
})
const emptySection = () => ({
_key: `sec-${++_keyCounter}`,
title: '',
title_cz: '',
content: ''
})
export default function OfferDetail() {
const { id } = useParams()
const isEdit = Boolean(id)
@@ -38,361 +22,35 @@ export default function OfferDetail() {
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState({})
const [customers, setCustomers] = useState([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
const [itemTemplates, setItemTemplates] = useState([])
const [scopeTemplates, setScopeTemplates] = useState([])
const {
loading, saving, errors, setErrors,
form, updateForm, items, setItems, sections,
customers, itemTemplates, scopeTemplates,
orderInfo, offerStatus, setOfferStatus,
totals, draftSavedAtLabel,
selectCustomer, clearCustomer,
updateItem, addItem, removeItem, addItemFromTemplate,
addSection, removeSection, updateSection, moveSection,
loadScopeTemplate, handleSave
} = useOfferForm({ id, isEdit, alert, navigate })
const [showItemTemplateMenu, setShowItemTemplateMenu] = useState(false)
const [showScopeTemplateMenu, setShowScopeTemplateMenu] = useState(false)
const [form, setForm] = useState({
quotation_number: '',
project_code: '',
customer_id: null,
customer_name: '',
created_at: new Date().toISOString().split('T')[0],
valid_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
currency: 'EUR',
language: 'EN',
vat_rate: 21,
apply_vat: false,
exchange_rate: '',
exchange_rate_date: '',
scope_title: '',
scope_description: ''
})
const [items, setItems] = useState([emptyItem()])
const [sections, setSections] = useState([])
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [orderInfo, setOrderInfo] = useState(null)
const [creatingOrder, setCreatingOrder] = useState(false)
const [showOrderModal, setShowOrderModal] = useState(false)
const [offerStatus, setOfferStatus] = useState('active')
const [invalidateConfirm, setInvalidateConfirm] = useState(false)
const [invalidatingOffer, setInvalidatingOffer] = useState(false)
useModalLock(showOrderModal)
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState(null)
const [pdfLoading, setPdfLoading] = useState(false)
const DRAFT_KEY = 'boha_offer_draft'
const [draftSavedAt, setDraftSavedAt] = useState(null)
const draftDataRef = useRef({ form, items, sections })
const draftRestoredRef = useRef(false)
useModalLock(showOrderModal)
// Fetch customers + templates on mount
useEffect(() => {
const fetchMeta = async () => {
try {
const [custRes, itemTplRes, scopeTplRes] = await Promise.all([
apiFetch(`${API_BASE}/customers.php`),
apiFetch(`${API_BASE}/offers-templates.php?action=items`),
apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
])
const custData = await custRes.json()
const itemTplData = await itemTplRes.json()
const scopeTplData = await scopeTplRes.json()
if (custData.success) setCustomers(custData.data.customers)
if (itemTplData.success) setItemTemplates(itemTplData.data.templates)
if (scopeTplData.success) setScopeTemplates(scopeTplData.data.templates)
} catch { /* silent */ }
}
fetchMeta()
}, [])
// Restore draft on mount (new offers only)
useEffect(() => {
if (isEdit) return
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const draft = JSON.parse(raw)
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
localStorage.removeItem(DRAFT_KEY)
return
}
const { form: dForm, items: dItems, sections: dSections, savedAt } = draft
setForm(prev => ({
...prev,
project_code: dForm.project_code ?? prev.project_code,
customer_id: dForm.customer_id ?? prev.customer_id,
customer_name: dForm.customer_name ?? prev.customer_name,
created_at: dForm.created_at ?? prev.created_at,
valid_until: dForm.valid_until ?? prev.valid_until,
currency: dForm.currency ?? prev.currency,
language: dForm.language ?? prev.language,
vat_rate: dForm.vat_rate ?? prev.vat_rate,
apply_vat: dForm.apply_vat ?? prev.apply_vat,
exchange_rate: dForm.exchange_rate ?? prev.exchange_rate,
exchange_rate_date: dForm.exchange_rate_date ?? prev.exchange_rate_date,
scope_title: dForm.scope_title ?? prev.scope_title,
scope_description: dForm.scope_description ?? prev.scope_description,
}))
if (dItems.length) setItems(dItems.map(i => ({ ...i, _key: i._key || `item-${++_keyCounter}` })))
if (Array.isArray(dSections) && dSections.length) setSections(dSections.map(s => ({ ...s, _key: s._key || `sec-${++_keyCounter}` })))
draftRestoredRef.current = true
if (savedAt) setDraftSavedAt(new Date(savedAt))
} catch {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}
}, [isEdit])
useEffect(() => {
draftDataRef.current = { form, items, sections }
}, [form, items, sections])
// Auto-save draft (jen nove nabidky)
useEffect(() => {
if (isEdit) return
const timer = setTimeout(() => {
try {
const { form: f, items: it, sections: sc } = draftDataRef.current
const { quotation_number: _qn, ...formWithoutNumber } = f
const savedAt = new Date().toISOString()
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formWithoutNumber, items: it, sections: sc, savedAt }))
setDraftSavedAt(new Date(savedAt))
} catch { /* ignore */ }
}, 500)
return () => clearTimeout(timer)
}, [form, items, sections, isEdit])
useEffect(() => {
if (!isEdit) {
const fetchNextNumber = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=next_number`)
const result = await response.json()
if (result.success) {
setForm(prev => ({ ...prev, quotation_number: result.data.number }))
}
} catch { /* ignore */ }
}
// Fetch default settings
const fetchDefaults = async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings.php`)
const result = await response.json()
if (result.success && !draftRestoredRef.current) {
const s = result.data
setForm(prev => ({
...prev,
currency: s.default_currency || prev.currency,
vat_rate: s.default_vat_rate || prev.vat_rate
}))
}
} catch { /* ignore */ }
}
fetchNextNumber()
fetchDefaults()
return
}
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const q = result.data
setForm({
quotation_number: q.quotation_number || '',
project_code: q.project_code || '',
customer_id: q.customer_id || null,
customer_name: q.customer_name || '',
created_at: (q.created_at || '').substring(0, 10),
valid_until: (q.valid_until || '').substring(0, 10),
currency: q.currency || 'EUR',
language: q.language || 'EN',
vat_rate: q.vat_rate || 21,
apply_vat: Boolean(q.apply_vat),
exchange_rate: q.exchange_rate || '',
exchange_rate_date: q.exchange_rate_date || '',
scope_title: q.scope_title || '',
scope_description: q.scope_description || ''
})
if (q.items?.length) {
setItems(q.items.map(item => ({
_key: `item-${++_keyCounter}`,
description: item.description || '',
item_description: item.item_description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
is_included_in_total: Boolean(item.is_included_in_total)
})))
}
if (q.sections?.length) {
setSections(q.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
})))
}
setOrderInfo(q.order || null)
setOfferStatus(q.status || 'active')
} 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)
}
}
fetchDetail()
}, [isEdit, id, alert, navigate])
// Close customer dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
// Calculated totals
const totals = useMemo(() => {
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 * ((Number(form.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [items, form.apply_vat, form.vat_rate])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
// Draft helpers
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraftSavedAt(null)
}, [])
const draftSavedAtLabel = useMemo(() => {
if (!draftSavedAt) return null
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}, [draftSavedAt])
// Form handlers
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const selectCustomer = (customer) => {
setForm(prev => ({
...prev,
customer_id: customer.id,
customer_name: customer.name
}))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
// Items handlers
const updateItem = (index, field, value) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index) => {
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}
const addItemFromTemplate = (template) => {
setItems(prev => [...prev, {
_key: `item-${++_keyCounter}`,
description: template.name || '',
item_description: template.description || '',
quantity: 1,
unit: '',
unit_price: Number(template.default_price) || 0,
is_included_in_total: true
}])
setShowItemTemplateMenu(false)
}
// Sections handlers
const addSection = () => setSections(prev => [...prev, emptySection()])
const removeSection = (index) => {
setSections(prev => prev.filter((_, i) => i !== index))
}
const updateSection = (index, field, value) => {
setSections(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
}
const moveSection = (index, direction) => {
setSections(prev => {
const newSections = [...prev]
const target = index + direction
if (target < 0 || target >= newSections.length) return prev
;[newSections[index], newSections[target]] = [newSections[target], newSections[index]]
return newSections
})
}
const loadScopeTemplate = async (template) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${template.id}`)
const result = await response.json()
if (result.success) {
// Load template-level fields into the quotation form
const tpl = result.data
setForm(prev => ({
...prev,
scope_description: tpl.description || prev.scope_description
}))
// Load sections
if (tpl.sections) {
const newSections = tpl.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
}))
setSections(prev => [...prev, ...newSections])
}
alert.success(`Načtena šablona "${template.name}"`)
}
} catch {
alert.error('Nepodařilo se načíst šablonu')
}
setShowScopeTemplateMenu(false)
}
const isInvalidated = offerStatus === 'invalidated'
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim()) {
@@ -426,79 +84,6 @@ export default function OfferDetail() {
}
}
// Save
const handleSave = async () => {
const newErrors = {}
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
if (!form.created_at) newErrors.created_at = 'Zadejte datum'
if (!form.valid_until) newErrors.valid_until = 'Zadejte datum'
if (items.length === 0 || items.every(i => !i.description.trim())) {
newErrors.items = 'Přidejte alespoň jednu položku'
}
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const payload = {
quotation: {
project_code: form.project_code,
customer_id: form.customer_id,
created_at: form.created_at,
valid_until: form.valid_until,
currency: form.currency,
language: form.language,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
exchange_rate: form.exchange_rate || null,
exchange_rate_date: form.exchange_rate_date || null,
scope_title: form.scope_title,
scope_description: form.scope_description
},
items: items.map((item, i) => ({
...item,
position: i + 1
})),
sections: sections.map((s, i) => ({
...s,
position: i + 1
}))
}
const url = isEdit
? `${API_BASE}/offers.php?id=${id}`
: `${API_BASE}/offers.php`
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
alert.success(result.message || (isEdit ? 'Nabídka byla uložena' : 'Nabídka byla vytvořena'))
if (!isEdit && result.data?.id) {
clearDraft()
const newId = result.data.id
setTimeout(() => navigate(`/offers/${newId}`, { replace: true }), 300)
}
} else {
alert.error(result.error || 'Nepodařilo se uložit nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const [pdfLoading, setPdfLoading] = useState(false)
const isInvalidated = offerStatus === 'invalidated'
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
const handleInvalidateOffer = async () => {
setInvalidatingOffer(true)
try {
@@ -520,13 +105,6 @@ export default function OfferDetail() {
}
}
const getRequiredPerm = () => {
if (!isEdit) return 'offers.create'
return isInvalidated ? 'offers.view' : 'offers.edit'
}
const requiredPerm = getRequiredPerm()
if (!hasPermission(requiredPerm)) return <Forbidden />
const handleDelete = async () => {
setDeleting(true)
try {
@@ -573,6 +151,13 @@ export default function OfferDetail() {
}
}
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' }}>
@@ -755,54 +340,15 @@ export default function OfferDetail() {
readOnly={isInvalidated}
/>
</div>
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Zákazník</label>
{form.customer_id && (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && (
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="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>
)}
{!form.customer_id && !isInvalidated && (
<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..."
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 10).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>
)}
{errors.customer_id && <span className="admin-form-error">{errors.customer_id}</span>}
</div>
<OfferCustomerPicker
customers={customers}
customerId={form.customer_id}
customerName={form.customer_name}
onSelect={selectCustomer}
onClear={clearCustomer}
error={errors.customer_id}
readOnly={isInvalidated}
/>
</div>
<div className="admin-form-row">
@@ -849,10 +395,10 @@ export default function OfferDetail() {
className="admin-form-select"
disabled={isInvalidated}
>
<option value="EUR">EUR ()</option>
<option value="EUR">EUR (&euro;)</option>
<option value="USD">USD ($)</option>
<option value="CZK">CZK ()</option>
<option value="GBP">GBP (£)</option>
<option value="GBP">GBP (&pound;)</option>
</select>
</div>
<div className="admin-form-group">