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:
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user