- pridano 20 utility trid (flex-1, mb-2, text-right, fw-500, admin-spinner-sm, atd.) - nahrazeno ~100 opakovanych inline stylu ve 39 JSX souborech - slouceno leave.css, orders.css, projects.css do admin.css (status badges) - bundle size: 228.91 -> 228.43 kB (-0.48 kB) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
212 lines
9.9 KiB
JavaScript
212 lines
9.9 KiB
JavaScript
import { motion } from 'framer-motion'
|
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'
|
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
|
import { formatCurrency } from '../utils/formatters'
|
|
import SortableRow, { DragHandle } from './SortableRow'
|
|
import useSortableList from '../hooks/useSortableList'
|
|
|
|
export default function OfferItemsSection({
|
|
items, setItems, updateItem, addItem, removeItem,
|
|
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
|
|
addItemFromTemplate, totals, currency, applyVat, vatRate,
|
|
itemsError, readOnly
|
|
}) {
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
|
useSensor(KeyboardSensor)
|
|
)
|
|
const { handleDragEnd } = useSortableList(setItems, '_key')
|
|
return (
|
|
<motion.div
|
|
className="offers-editor-section"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.2 }}
|
|
>
|
|
<div className="flex-between mb-4">
|
|
<div>
|
|
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
|
|
{itemsError && <span className="admin-form-error">{itemsError}</span>}
|
|
</div>
|
|
{!readOnly && (
|
|
<div style={{ display: 'flex', gap: '0.5rem', position: 'relative' }}>
|
|
{itemTemplates.length > 0 && (
|
|
<div style={{ position: 'relative' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowItemTemplateMenu(prev => !prev)}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
>
|
|
Ze šablony
|
|
</button>
|
|
{showItemTemplateMenu && (
|
|
<div className="offers-template-menu">
|
|
{itemTemplates.map(t => (
|
|
<div
|
|
key={t.id}
|
|
className="offers-template-menu-item"
|
|
onClick={() => addItemFromTemplate(t)}
|
|
>
|
|
<div className="fw-500">{t.name}</div>
|
|
{t.default_price > 0 && (
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
|
{Number(t.default_price).toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">
|
|
+ Přidat položku
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="offers-items-table">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
{!readOnly && <th style={{ width: '2rem' }}></th>}
|
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
|
<th>Popis položky</th>
|
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
|
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
|
|
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
|
{!readOnly && <th style={{ width: '2.5rem', textAlign: 'center' }}></th>}
|
|
</tr>
|
|
</thead>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
|
|
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
|
|
<tbody>
|
|
{items.map((item, index) => {
|
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
|
return (
|
|
<SortableRow key={item._key} id={String(item._key)} disabled={readOnly}>
|
|
{({ attributes, listeners }) => (
|
|
<>
|
|
{!readOnly && (
|
|
<td style={{ width: '2rem' }}>
|
|
<DragHandle listeners={listeners} attributes={attributes} />
|
|
</td>
|
|
)}
|
|
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
value={item.description}
|
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="Název položky"
|
|
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
|
|
readOnly={readOnly}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={item.item_description}
|
|
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="Podrobný popis (volitelný)"
|
|
style={{ fontSize: '0.8rem', opacity: 0.8 }}
|
|
readOnly={readOnly}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
value={item.quantity}
|
|
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
|
className="admin-form-input"
|
|
min="0"
|
|
step="1"
|
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
|
readOnly={readOnly}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
value={item.unit}
|
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="hod"
|
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
|
readOnly={readOnly}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
value={item.unit_price}
|
|
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
|
className="admin-form-input"
|
|
min="0"
|
|
step="0.01"
|
|
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
|
readOnly={readOnly}
|
|
/>
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.is_included_in_total}
|
|
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
|
|
disabled={readOnly}
|
|
/>
|
|
<span></span>
|
|
</label>
|
|
</td>
|
|
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
|
{formatCurrency(lineTotal, currency)}
|
|
</td>
|
|
{!readOnly && (
|
|
<td>
|
|
{items.length > 1 && (
|
|
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
|
<svg width="12" height="12" 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>
|
|
)}
|
|
</>
|
|
)}
|
|
</SortableRow>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Totals */}
|
|
<div className="offers-totals-summary">
|
|
<div className="offers-totals-row">
|
|
<span>Mezisoučet:</span>
|
|
<span>{formatCurrency(totals.subtotal, currency)}</span>
|
|
</div>
|
|
{applyVat && (
|
|
<div className="offers-totals-row">
|
|
<span>DPH ({vatRate}%):</span>
|
|
<span>{formatCurrency(totals.vatAmount, currency)}</span>
|
|
</div>
|
|
)}
|
|
<div className="offers-totals-row offers-totals-total">
|
|
<span>Celkem k úhradě:</span>
|
|
<span>{formatCurrency(totals.total, currency)}</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|