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() {
-
-
-
-
- | # |
- Popis |
- Množství |
- Jednotka |
- Jedn. cena |
- {form.apply_vat ? DPH | : null}
- Celkem |
- |
-
-
-
- {items.map((item, index) => {
- const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
- return (
-
- | {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' }} />
- |
- {form.apply_vat ? (
-
-
- |
- ) : null}
-
- {formatCurrency(lineTotal, form.currency)}
- |
-
- {items.length > 1 && (
-
- )}
- |
+
+ i._key)} strategy={verticalListSortingStrategy}>
+
+
+
+
+ |
+ # |
+ Popis |
+ Množství |
+ Jednotka |
+ Jedn. cena |
+ {form.apply_vat ? DPH | : null}
+ Celkem |
+ |
- )
- })}
-
-
-
+
+
+ {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 ? (
-
-
-
-
- | # |
- Popis |
- Množství |
- Jednotka |
- Jedn. cena |
- %DPH |
- |
-
-
-
- {editItems.map((item, index) => (
-
- | {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}>
+
+
+
+
+ |
+ # |
+ Popis |
+ Množství |
+ Jednotka |
+ Jedn. cena |
+ %DPH |
+ |
+
+
+
+ {editItems.map((item, index) => (
+ 1}
+ />
+ ))}
+
+
+
+
+
) : (
<>
{invoice.items?.length > 0 ? (