feat: add drag-and-drop item reordering to invoice create and edit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@ import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
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, restrictToParentElement } from '@dnd-kit/modifiers'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import FormField from '../components/FormField'
|
||||
@@ -71,6 +75,60 @@ interface Invoice {
|
||||
valid_transitions?: string[]
|
||||
}
|
||||
|
||||
function SortableInvoiceEditRow({ item, index, apply_vat, onUpdate, onRemove, canDelete }: {
|
||||
item: EditItem; index: number; apply_vat: boolean;
|
||||
onUpdate: (index: number, field: string, value: string | number) => void;
|
||||
onRemove: (index: number) => void; canDelete: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key })
|
||||
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,
|
||||
}
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style}>
|
||||
<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 className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||
<td><input type="text" value={item.description} onChange={(e) => onUpdate(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /></td>
|
||||
<td><input type="number" value={item.quantity} onChange={(e) => onUpdate(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td><input type="text" value={item.unit} onChange={(e) => onUpdate(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td><input type="number" value={item.unit_price} onChange={(e) => onUpdate(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td>
|
||||
{apply_vat ? (
|
||||
<select value={item.vat_rate} onChange={(e) => onUpdate(index, 'vat_rate', Number(e.target.value))} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}>
|
||||
{VAT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-tertiary" style={{ display: 'block', textAlign: 'center' }}>0%</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
|
||||
{canDelete && (
|
||||
<button type="button" onClick={() => onRemove(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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const alert = useAlert()
|
||||
@@ -92,6 +150,11 @@ export default function InvoiceDetail() {
|
||||
const [editingItems, setEditingItems] = useState(false)
|
||||
const [editItems, setEditItems] = useState<EditItem[]>([])
|
||||
const editKeyCounter = useRef(0)
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
)
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -236,6 +299,17 @@ export default function InvoiceDetail() {
|
||||
setEditItems(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
setEditItems(prev => {
|
||||
const oldIndex = prev.findIndex(i => i._key === String(active.id))
|
||||
const newIndex = prev.findIndex(i => i._key === String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
|
||||
const saveEditItems = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -415,52 +489,39 @@ export default function InvoiceDetail() {
|
||||
</div>
|
||||
|
||||
{editingItems ? (
|
||||
<div className="offers-items-table">
|
||||
<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: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||
<th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th>
|
||||
<th style={{ width: '5.5rem' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editItems.map((item, index) => (
|
||||
<tr key={item._key}>
|
||||
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||
<td><input type="text" value={item.description} onChange={(e) => updateEditItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /></td>
|
||||
<td><input type="number" value={item.quantity} onChange={(e) => updateEditItem(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td><input type="text" value={item.unit} onChange={(e) => updateEditItem(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td><input type="number" value={item.unit_price} onChange={(e) => updateEditItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
|
||||
<td>
|
||||
{Number(invoice.apply_vat) ? (
|
||||
<select value={item.vat_rate} onChange={(e) => updateEditItem(index, 'vat_rate', Number(e.target.value))} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}>
|
||||
{VAT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-tertiary" style={{ display: 'block', textAlign: 'center' }}>0%</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
|
||||
{editItems.length > 1 && (
|
||||
<button type="button" onClick={() => removeEditItem(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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DndContext sensors={dndSensors} collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={editItems.map(i => i._key)} strategy={verticalListSortingStrategy}>
|
||||
<div className="offers-items-table">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '2rem' }} />
|
||||
<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: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||
<th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th>
|
||||
<th style={{ width: '5.5rem' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editItems.map((item, index) => (
|
||||
<SortableInvoiceEditRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
apply_vat={!!Number(invoice.apply_vat)}
|
||||
onUpdate={updateEditItem}
|
||||
onRemove={removeEditItem}
|
||||
canDelete={editItems.length > 1}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<>
|
||||
{invoice.items?.length > 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user