From 892d83cd90600ed1e00219311f4315fba4725dbd Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 19:18:01 +0100 Subject: [PATCH] feat: add drag-and-drop item reordering to invoice create and edit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/admin/pages/InvoiceCreate.tsx | 172 ++++++++++++++++++++---------- src/admin/pages/InvoiceDetail.tsx | 153 ++++++++++++++++++-------- 2 files changed, 223 insertions(+), 102 deletions(-) diff --git a/src/admin/pages/InvoiceCreate.tsx b/src/admin/pages/InvoiceCreate.tsx index 38e0c63..a64b976 100644 --- a/src/admin/pages/InvoiceCreate.tsx +++ b/src/admin/pages/InvoiceCreate.tsx @@ -6,6 +6,10 @@ import Forbidden from '../components/Forbidden' import FormField from '../components/FormField' import AdminDatePicker from '../components/AdminDatePicker' 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 { formatCurrency } from '../utils/formatters' @@ -64,6 +68,68 @@ interface InvoiceForm { 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 ( + + + + + {index + 1} + + onUpdate(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> + + + 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' }} /> + + + onUpdate(index, 'unit', e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> + + + onUpdate(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> + + {apply_vat ? ( + + + + ) : null} + + {formatCurrency(lineTotal, currency)} + + + {canDelete && ( + + )} + + + ) +} + export default function InvoiceCreate() { const keyCounterRef = useRef(0) const emptyItem = useCallback((): InvoiceItem => ({ @@ -74,6 +140,11 @@ export default function InvoiceCreate() { unit_price: 0, vat_rate: 21 }), []) + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + useSensor(KeyboardSensor), + ) const navigate = useNavigate() const [searchParams] = useSearchParams() const alert = useAlert() @@ -268,6 +339,17 @@ export default function InvoiceCreate() { 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 const totals = useMemo(() => { let subtotal = 0 @@ -503,63 +585,41 @@ export default function InvoiceCreate() { -
- - - - - - - - - {form.apply_vat ? : null} - - - - - - {items.map((item, index) => { - const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) - return ( - - - - - - - {form.apply_vat ? ( - - ) : null} - - + + i._key)} strategy={verticalListSortingStrategy}> +
+
#PopisMnožstvíJednotkaJedn. cenaDPHCelkem
{index + 1} - updateItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> - - 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' }} /> - - updateItem(index, 'unit', e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> - - updateItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> - - - - {formatCurrency(lineTotal, form.currency)} - - {items.length > 1 && ( - - )} -
+ + + + + + + + {form.apply_vat ? : null} + + - ) - })} - -
+ #PopisMnožstvíJednotkaJedn. cenaDPHCelkem
-
+ + + {items.map((item, index) => ( + 1} + /> + ))} + + + + + {/* Totals */}
diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 8a9a095..0c4f547 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -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 ( + + + + + {index + 1} + onUpdate(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> + 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' }} /> + onUpdate(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> + onUpdate(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> + + {apply_vat ? ( + + ) : ( + 0% + )} + + +
+ {canDelete && ( + + )} +
+ + + ) +} + 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([]) 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() {
{editingItems ? ( -
- - - - - - - - - - - - - - {editItems.map((item, index) => ( - - - - - - - - - - ))} - -
#PopisMnožstvíJednotkaJedn. cena%DPH
{index + 1} updateEditItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /> 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' }} /> updateEditItem(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> updateEditItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /> - {Number(invoice.apply_vat) ? ( - - ) : ( - 0% - )} - -
- {editItems.length > 1 && ( - - )} -
-
-
+ + i._key)} strategy={verticalListSortingStrategy}> +
+ + + + + + + + + + + + + + {editItems.map((item, index) => ( + 1} + /> + ))} + +
+ #PopisMnožstvíJednotkaJedn. cena%DPH
+
+
+
) : ( <> {invoice.items?.length > 0 ? (