feat: drag & drop razeni polozek pri vytvareni faktury
Nahrazeny tlacitka nahoru/dolu za @dnd-kit drag & drop (SortableRow + DragHandle), stejny pattern jako v nabidkach. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,11 @@ 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 } from '@dnd-kit/core'
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||||
|
import SortableRow, { DragHandle } from '../components/SortableRow'
|
||||||
|
import useSortableList from '../hooks/useSortableList'
|
||||||
import apiFetch from '../utils/api'
|
import apiFetch from '../utils/api'
|
||||||
import { formatCurrency } from '../utils/formatters'
|
import { formatCurrency } from '../utils/formatters'
|
||||||
|
|
||||||
@@ -315,15 +320,12 @@ export default function InvoiceCreate() {
|
|||||||
setItems(prev => prev.filter((_, i) => i !== index))
|
setItems(prev => prev.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveItem = (index, direction) => {
|
const sensors = useSensors(
|
||||||
setItems(prev => {
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
const newItems = [...prev]
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||||
const target = index + direction
|
useSensor(KeyboardSensor)
|
||||||
if (target < 0 || target >= newItems.length) return prev
|
)
|
||||||
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]]
|
const { handleDragEnd } = useSortableList(setItems, '_key')
|
||||||
return newItems
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
@@ -651,105 +653,114 @@ export default function InvoiceCreate() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="offers-items-table">
|
<div className="offers-items-table">
|
||||||
<table className="admin-table">
|
<DndContext
|
||||||
<thead>
|
sensors={sensors}
|
||||||
<tr>
|
collisionDetection={closestCenter}
|
||||||
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
modifiers={[restrictToVerticalAxis]}
|
||||||
<th>Popis</th>
|
onDragEnd={handleDragEnd}
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
<table className="admin-table">
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
<thead>
|
||||||
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
|
<tr>
|
||||||
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
<th style={{ width: '2rem' }}></th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}></th>
|
<th style={{ width: '2rem', textAlign: 'center' }}>#</th>
|
||||||
</tr>
|
<th>Popis</th>
|
||||||
</thead>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
||||||
<tbody>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
||||||
{items.map((item, index) => {
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
|
||||||
return (
|
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
||||||
<tr key={item._key || index}>
|
<th style={{ width: '2.5rem' }}></th>
|
||||||
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<input
|
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
|
||||||
type="text"
|
<tbody>
|
||||||
value={item.description}
|
{items.map((item, index) => {
|
||||||
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
className="admin-form-input"
|
return (
|
||||||
placeholder="Popis položky..."
|
<SortableRow key={item._key} id={String(item._key)}>
|
||||||
style={{ fontWeight: 500 }}
|
{({ attributes, listeners }) => (
|
||||||
/>
|
<>
|
||||||
</td>
|
<td style={{ width: '2rem' }}>
|
||||||
<td>
|
<DragHandle listeners={listeners} attributes={attributes} />
|
||||||
<input
|
</td>
|
||||||
type="number"
|
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||||
value={item.quantity}
|
<td>
|
||||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
<input
|
||||||
className="admin-form-input"
|
type="text"
|
||||||
min="0"
|
value={item.description}
|
||||||
step="any"
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
||||||
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
className="admin-form-input"
|
||||||
/>
|
placeholder="Popis položky..."
|
||||||
</td>
|
style={{ fontWeight: 500 }}
|
||||||
<td>
|
/>
|
||||||
<input
|
</td>
|
||||||
type="text"
|
<td>
|
||||||
value={item.unit}
|
<input
|
||||||
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
type="number"
|
||||||
className="admin-form-input"
|
value={item.quantity}
|
||||||
placeholder="ks"
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||||
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
className="admin-form-input"
|
||||||
/>
|
min="0"
|
||||||
</td>
|
step="any"
|
||||||
<td>
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
<input
|
/>
|
||||||
type="number"
|
</td>
|
||||||
value={item.unit_price}
|
<td>
|
||||||
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
|
<input
|
||||||
className="admin-form-input"
|
type="text"
|
||||||
step="any"
|
value={item.unit}
|
||||||
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
/>
|
className="admin-form-input"
|
||||||
</td>
|
placeholder="ks"
|
||||||
{form.apply_vat ? (
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
<td>
|
/>
|
||||||
<select
|
</td>
|
||||||
value={item.vat_rate}
|
<td>
|
||||||
onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))}
|
<input
|
||||||
className="admin-form-input"
|
type="number"
|
||||||
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
value={item.unit_price}
|
||||||
>
|
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
|
||||||
{VAT_OPTIONS.map(o => (
|
className="admin-form-input"
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
step="any"
|
||||||
))}
|
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
</select>
|
/>
|
||||||
</td>
|
</td>
|
||||||
) : null}
|
{form.apply_vat ? (
|
||||||
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
<td>
|
||||||
{formatCurrency(lineTotal, form.currency)}
|
<select
|
||||||
</td>
|
value={item.vat_rate}
|
||||||
<td>
|
onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))}
|
||||||
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
|
className="admin-form-input"
|
||||||
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
>
|
||||||
</button>
|
{VAT_OPTIONS.map(o => (
|
||||||
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
))}
|
||||||
</button>
|
</select>
|
||||||
{items.length > 1 && (
|
</td>
|
||||||
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
) : null}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
{formatCurrency(lineTotal, form.currency)}
|
||||||
</svg>
|
</td>
|
||||||
</button>
|
<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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SortableRow>
|
||||||
</td>
|
)
|
||||||
</tr>
|
})}
|
||||||
)
|
</tbody>
|
||||||
})}
|
</SortableContext>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Soucty */}
|
{/* Soucty */}
|
||||||
|
|||||||
Reference in New Issue
Block a user