feat: offer items drag-and-drop reordering + fix scope template insertion

1. Item reordering: replaced placeholder with @dnd-kit drag-and-drop.
   Each item row has a drag handle for reordering via vertical drag.
   Uses SortableContext with verticalListSortingStrategy.

2. Scope template insertion: fixed template loading to use already-fetched
   data instead of re-fetching from non-existent endpoint. Templates with
   sections are now stored fully and inserted directly on selection.
   Also copies template description to scope_description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 19:02:15 +01:00
parent 95065f54eb
commit 185157fe86
3 changed files with 215 additions and 110 deletions

71
package-lock.json generated
View File

@@ -9,6 +9,10 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
@@ -53,6 +57,73 @@
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",

View File

@@ -24,6 +24,10 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",

View File

@@ -4,6 +4,10 @@ import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion' 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 } from '@dnd-kit/modifiers'
import { CSS } from '@dnd-kit/utilities'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField' import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden' import Forbidden from '../components/Forbidden'
@@ -18,6 +22,7 @@ const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_offer_draft' const DRAFT_KEY = 'boha_offer_draft'
interface OfferItem { interface OfferItem {
_key: string
id?: number id?: number
description: string description: string
item_description: string item_description: string
@@ -27,6 +32,9 @@ interface OfferItem {
is_included_in_total: boolean is_included_in_total: boolean
} }
let _itemKeyCounter = 0
const nextItemKey = () => `item-${++_itemKeyCounter}`
interface ScopeSection { interface ScopeSection {
title: string title: string
title_cz: string title_cz: string
@@ -83,6 +91,7 @@ const emptyScopeSection = (): ScopeSection => ({
}) })
const emptyItem = (): OfferItem => ({ const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: '', description: '',
item_description: '', item_description: '',
quantity: 1, quantity: 1,
@@ -91,6 +100,70 @@ const emptyItem = (): OfferItem => ({
is_included_in_total: true, 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>
<input type="text" value={item.description} onChange={e => onUpdate('description', e.target.value)}
className="admin-form-input" placeholder="Název položky" readOnly={readOnly} />
</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() { export default function OfferDetail() {
const { id } = useParams() const { id } = useParams()
const isEdit = Boolean(id) const isEdit = Boolean(id)
@@ -104,7 +177,7 @@ export default function OfferDetail() {
const [form, setForm] = useState<OfferForm>(emptyForm) const [form, setForm] = useState<OfferForm>(emptyForm)
const [items, setItems] = useState<OfferItem[]>([emptyItem()]) const [items, setItems] = useState<OfferItem[]>([emptyItem()])
const [sections, setSections] = useState<ScopeSection[]>([]) const [sections, setSections] = useState<ScopeSection[]>([])
const [scopeTemplates, setScopeTemplates] = useState<Array<{ id: number; name: string }>>([]) 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 [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('') const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false) const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
@@ -150,7 +223,7 @@ export default function OfferDetail() {
scope_title: d.scope_title || '', scope_title: d.scope_title || '',
scope_description: d.scope_description || '', scope_description: d.scope_description || '',
}) })
setItems(d.items?.length ? d.items : [emptyItem()]) setItems(d.items?.length ? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) : [emptyItem()])
setSections(d.sections?.length ? d.sections.map((s: any) => ({ setSections(d.sections?.length ? d.sections.map((s: any) => ({
title: s.title || '', title: s.title || '',
title_cz: s.title_cz || '', title_cz: s.title_cz || '',
@@ -189,7 +262,7 @@ export default function OfferDetail() {
if (res.status === 401) return if (res.status === 401) return
const data = await res.json() const data = await res.json()
if (data.success && Array.isArray(data.data)) { if (data.success && Array.isArray(data.data)) {
setScopeTemplates(data.data.map((t: any) => ({ id: t.id, name: t.name }))) setScopeTemplates(data.data)
} }
} catch { /* silent */ } } catch { /* silent */ }
} }
@@ -734,7 +807,7 @@ export default function OfferDetail() {
</div> </div>
</motion.div> </motion.div>
{/* Items Section (simplified - no drag-and-drop) */} {/* Items Section with drag-and-drop */}
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -753,94 +826,57 @@ export default function OfferDetail() {
{errors.items && <p style={{ color: 'var(--color-danger)', fontSize: '0.85rem' }}>{errors.items}</p>} {errors.items && <p style={{ color: 'var(--color-danger)', fontSize: '0.85rem' }}>{errors.items}</p>}
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <DndContext
<thead> sensors={useSensors(
<tr> useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th> useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
<th>Popis</th> useSensor(KeyboardSensor),
<th style={{ width: '5rem' }}>Množství</th> )}
<th style={{ width: '5rem' }}>Jednotka</th> collisionDetection={closestCenter}
<th style={{ width: '7rem' }}>Cena/ks</th> modifiers={[restrictToVerticalAxis]}
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th> onDragEnd={(event: DragEndEvent) => {
<th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th> const { active, over } = event
{!isInvalidated && <th style={{ width: '3rem' }} />} if (!over || active.id === over.id) return
</tr> setItems(prev => {
</thead> const oldIndex = prev.findIndex(i => i._key === String(active.id))
<tbody> const newIndex = prev.findIndex(i => i._key === String(over.id))
{items.map((item, index) => { if (oldIndex === -1 || newIndex === -1) return prev
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) return arrayMove(prev, oldIndex, newIndex)
return ( })
<tr key={index}> }}
<td style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>{index + 1}</td> >
<td> <SortableContext items={items.map(i => i._key)} strategy={verticalListSortingStrategy}>
<input <table className="admin-table">
type="text" <thead>
value={item.description} <tr>
onChange={(e) => updateItem(index, 'description', e.target.value)} {!isInvalidated && <th style={{ width: '2rem' }} />}
className="admin-form-input" <th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
placeholder="Název položky" <th>Popis</th>
readOnly={isInvalidated} <th style={{ width: '5rem' }}>Množství</th>
/> <th style={{ width: '5rem' }}>Jednotka</th>
</td> <th style={{ width: '7rem' }}>Cena/ks</th>
<td> <th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
<input <th style={{ width: '7rem', textAlign: 'right' }}>Celkem</th>
type="number" {!isInvalidated && <th style={{ width: '3rem' }} />}
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="admin-form-input"
step="1"
readOnly={isInvalidated}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
className="admin-form-input"
readOnly={isInvalidated}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
className="admin-form-input"
step="0.01"
readOnly={isInvalidated}
/>
</td>
<td style={{ textAlign: 'center' }}>
<input
type="checkbox"
checked={item.is_included_in_total}
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
disabled={isInvalidated}
/>
</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>
{formatCurrency(lineTotal, form.currency)}
</td>
{!isInvalidated && (
<td>
<button
onClick={() => removeItem(index)}
className="admin-btn-icon danger"
title="Odebrat"
disabled={items.length <= 1}
>
<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> </tr>
) </thead>
})} <tbody>
</tbody> {items.map((item, index) => (
</table> <SortableItemRow
key={item._key}
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated}
canDelete={items.length > 1}
onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)}
/>
))}
</tbody>
</table>
</SortableContext>
</DndContext>
</div> </div>
{/* Totals */} {/* Totals */}
@@ -860,10 +896,6 @@ export default function OfferDetail() {
<span>{formatCurrency(total, form.currency)}</span> <span>{formatCurrency(total, form.currency)}</span>
</div> </div>
</div> </div>
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.8rem', marginTop: '1rem' }}>
Řazení položek přetažením bude dostupné v příští verzi.
</p>
</div> </div>
</motion.div> </motion.div>
@@ -884,23 +916,21 @@ export default function OfferDetail() {
className="admin-form-select" className="admin-form-select"
style={{ width: 'auto', minWidth: '160px' }} style={{ width: 'auto', minWidth: '160px' }}
defaultValue="" defaultValue=""
onChange={async (e) => { onChange={(e) => {
const templateId = e.target.value const templateId = Number(e.target.value)
if (!templateId) return if (!templateId) return
try { const template = scopeTemplates.find(t => t.id === templateId)
const res = await apiFetch(`${API_BASE}/offers-templates/${templateId}`) if (template?.scope_template_sections?.length) {
const data = await res.json() const newSections = template.scope_template_sections.map((s: any) => ({
if (data.success && data.data.sections) { title: s.title || '',
const newSections = data.data.sections.map((s: any) => ({ title_cz: s.title_cz || '',
title: s.title || '', content: s.content || '',
title_cz: s.title_cz || '', }))
content: s.content || '', setSections(prev => [...prev, ...newSections])
})) if (template.description) {
setSections(prev => [...prev, ...newSections]) setForm(prev => ({ ...prev, scope_description: template.description || prev.scope_description }))
alert.success('Sekce ze šablony byly přidány')
} }
} catch { alert.success(`Načtena šablona "${template.name}"`)
alert.error('Nepodařilo se načíst šablonu')
} }
e.target.value = '' e.target.value = ''
}} }}