feat: drag & drop razeni polozek pri vytvareni faktury

Nahrazeny tlacitka nahoru/dolu za @dnd-kit drag & drop (SortableRow + DragHandle),
stejny pattern jako v nabidkach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 19:51:00 +01:00
parent 86d292f763
commit adf202d421

View File

@@ -6,6 +6,11 @@ import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField' import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from '../components/AdminDatePicker'
import { motion } from 'framer-motion' 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 SortableRow, { DragHandle } from '../components/SortableRow'
import useSortableList from '../hooks/useSortableList'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters' import { formatCurrency } from '../utils/formatters'
@@ -315,15 +320,12 @@ export default function InvoiceCreate() {
setItems(prev => prev.filter((_, i) => i !== index)) setItems(prev => prev.filter((_, i) => i !== index))
} }
const moveItem = (index, direction) => { const sensors = useSensors(
setItems(prev => { useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
const newItems = [...prev] useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
const target = index + direction useSensor(KeyboardSensor)
if (target < 0 || target >= newItems.length) return prev )
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]] const { handleDragEnd } = useSortableList(setItems, '_key')
return newItems
})
}
// Totals // Totals
const totals = useMemo(() => { const totals = useMemo(() => {
@@ -651,24 +653,37 @@ export default function InvoiceCreate() {
</div> </div>
<div className="offers-items-table"> <div className="offers-items-table">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th> <th style={{ width: '2rem' }}></th>
<th style={{ width: '2rem', textAlign: 'center' }}>#</th>
<th>Popis</th> <th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</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' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null} {form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th> <th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}></th> <th style={{ width: '2.5rem' }}></th>
</tr> </tr>
</thead> </thead>
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
<tbody> <tbody>
{items.map((item, index) => { {items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return ( return (
<tr key={item._key || index}> <SortableRow key={item._key} id={String(item._key)}>
{({ attributes, listeners }) => (
<>
<td style={{ width: '2rem' }}>
<DragHandle listeners={listeners} attributes={attributes} />
</td>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td> <td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td> <td>
<input <input
@@ -729,13 +744,6 @@ export default function InvoiceCreate() {
{formatCurrency(lineTotal, form.currency)} {formatCurrency(lineTotal, form.currency)}
</td> </td>
<td> <td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
{items.length > 1 && ( {items.length > 1 && (
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat"> <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"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -743,13 +751,16 @@ export default function InvoiceCreate() {
</svg> </svg>
</button> </button>
)} )}
</div>
</td> </td>
</tr> </>
)}
</SortableRow>
) )
})} })}
</tbody> </tbody>
</SortableContext>
</table> </table>
</DndContext>
</div> </div>
{/* Soucty */} {/* Soucty */}