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:
BOHA
2026-03-23 19:18:01 +01:00
parent 2b4a98b958
commit 892d83cd90
2 changed files with 223 additions and 102 deletions

View File

@@ -6,6 +6,10 @@ 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, 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 apiFetch from '../utils/api' import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters' import { formatCurrency } from '../utils/formatters'
@@ -64,6 +68,68 @@ interface InvoiceForm {
bank_account: string bank_account: string
} }
function SortableInvoiceRow({ item, index, currency, apply_vat, onUpdate, onRemove, canDelete }: {
item: InvoiceItem; index: number; currency: string; apply_vat: boolean;
onUpdate: (index: number, field: keyof InvoiceItem, 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,
}
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
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 text-center fw-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" placeholder="ks" 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>
{apply_vat ? (
<td>
<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>
</td>
) : null}
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
{formatCurrency(lineTotal, currency)}
</td>
<td>
{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>
)}
</td>
</tr>
)
}
export default function InvoiceCreate() { export default function InvoiceCreate() {
const keyCounterRef = useRef(0) const keyCounterRef = useRef(0)
const emptyItem = useCallback((): InvoiceItem => ({ const emptyItem = useCallback((): InvoiceItem => ({
@@ -74,6 +140,11 @@ export default function InvoiceCreate() {
unit_price: 0, unit_price: 0,
vat_rate: 21 vat_rate: 21
}), []) }), [])
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
useSensor(KeyboardSensor),
)
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const alert = useAlert() const alert = useAlert()
@@ -268,6 +339,17 @@ export default function InvoiceCreate() {
setItems(prev => prev.filter((_, i) => i !== index)) setItems(prev => prev.filter((_, i) => i !== index))
} }
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
setItems(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)
})
}
// Totals // Totals
const totals = useMemo(() => { const totals = useMemo(() => {
let subtotal = 0 let subtotal = 0
@@ -503,63 +585,41 @@ export default function InvoiceCreate() {
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">+ Přidat položku</button> <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"> <DndContext sensors={dndSensors} collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={handleDragEnd}>
<table className="admin-table"> <SortableContext items={items.map(i => i._key)} strategy={verticalListSortingStrategy}>
<thead> <div className="offers-items-table">
<tr> <table className="admin-table">
<th style={{ width: '2rem', textAlign: 'center' }}>#</th> <thead>
<th>Popis</th> <tr>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th> <th style={{ width: '2rem' }} />
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th> <th style={{ width: '2rem', textAlign: 'center' }}>#</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th> <th>Popis</th>
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null} <th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '2.5rem' }}></th> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
</tr> {form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
</thead> <th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
<tbody> <th style={{ width: '2.5rem' }}></th>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key}>
<td className="text-tertiary text-center fw-500">{index + 1}</td>
<td>
<input type="text" value={item.description} onChange={(e) => updateItem(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) => updateItem(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) => updateItem(index, 'unit', e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} />
</td>
<td>
<input type="number" value={item.unit_price} onChange={(e) => updateItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} />
</td>
{form.apply_vat ? (
<td>
<select value={item.vat_rate} onChange={(e) => updateItem(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>
</td>
) : null}
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
{formatCurrency(lineTotal, form.currency)}
</td>
<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>
</tr> </tr>
) </thead>
})} <tbody>
</tbody> {items.map((item, index) => (
</table> <SortableInvoiceRow
</div> key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</tbody>
</table>
</div>
</SortableContext>
</DndContext>
{/* Totals */} {/* Totals */}
<div className="offers-totals-summary"> <div className="offers-totals-summary">

View File

@@ -3,6 +3,10 @@ import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext' 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, restrictToParentElement } from '@dnd-kit/modifiers'
import { CSS } from '@dnd-kit/utilities'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden' import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField' import FormField from '../components/FormField'
@@ -71,6 +75,60 @@ interface Invoice {
valid_transitions?: string[] 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() { export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const alert = useAlert() const alert = useAlert()
@@ -92,6 +150,11 @@ export default function InvoiceDetail() {
const [editingItems, setEditingItems] = useState(false) const [editingItems, setEditingItems] = useState(false)
const [editItems, setEditItems] = useState<EditItem[]>([]) const [editItems, setEditItems] = useState<EditItem[]>([])
const editKeyCounter = useRef(0) 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 () => { const fetchDetail = useCallback(async () => {
try { try {
@@ -236,6 +299,17 @@ export default function InvoiceDetail() {
setEditItems(prev => prev.filter((_, i) => i !== index)) 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 () => { const saveEditItems = async () => {
setSaving(true) setSaving(true)
try { try {
@@ -415,52 +489,39 @@ export default function InvoiceDetail() {
</div> </div>
{editingItems ? ( {editingItems ? (
<div className="offers-items-table"> <DndContext sensors={dndSensors} collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={handleDragEnd}>
<table className="admin-table"> <SortableContext items={editItems.map(i => i._key)} strategy={verticalListSortingStrategy}>
<thead> <div className="offers-items-table">
<tr> <table className="admin-table">
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th> <thead>
<th>Popis</th> <tr>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th> <th style={{ width: '2rem' }} />
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th> <th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th> <th>Popis</th>
<th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th> <th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem' }}></th> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
</tr> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
</thead> <th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th>
<tbody> <th style={{ width: '5.5rem' }}></th>
{editItems.map((item, index) => ( </tr>
<tr key={item._key}> </thead>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td> <tbody>
<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> {editItems.map((item, index) => (
<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> <SortableInvoiceEditRow
<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> key={item._key}
<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> item={item}
<td> index={index}
{Number(invoice.apply_vat) ? ( apply_vat={!!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' }}> onUpdate={updateEditItem}
{VAT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} onRemove={removeEditItem}
</select> canDelete={editItems.length > 1}
) : ( />
<span className="text-tertiary" style={{ display: 'block', textAlign: 'center' }}>0%</span> ))}
)} </tbody>
</td> </table>
<td> </div>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}> </SortableContext>
{editItems.length > 1 && ( </DndContext>
<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>
) : ( ) : (
<> <>
{invoice.items?.length > 0 ? ( {invoice.items?.length > 0 ? (