diff --git a/package-lock.json b/package-lock.json index 4e28835..f2b6659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", "@fastify/multipart": "^9.4.0", @@ -53,6 +57,73 @@ "vitest": "^4.1.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", diff --git a/package.json b/package.json index ceeefa6..7874f7c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", "@fastify/multipart": "^9.4.0", diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 00d12f1..1e20297 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -4,6 +4,10 @@ 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 } from '@dnd-kit/modifiers' +import { CSS } from '@dnd-kit/utilities' import ConfirmModal from '../components/ConfirmModal' import FormField from '../components/FormField' import Forbidden from '../components/Forbidden' @@ -18,6 +22,7 @@ const API_BASE = '/api/admin' const DRAFT_KEY = 'boha_offer_draft' interface OfferItem { + _key: string id?: number description: string item_description: string @@ -27,6 +32,9 @@ interface OfferItem { is_included_in_total: boolean } +let _itemKeyCounter = 0 +const nextItemKey = () => `item-${++_itemKeyCounter}` + interface ScopeSection { title: string title_cz: string @@ -83,6 +91,7 @@ const emptyScopeSection = (): ScopeSection => ({ }) const emptyItem = (): OfferItem => ({ + _key: nextItemKey(), description: '', item_description: '', quantity: 1, @@ -91,6 +100,70 @@ const emptyItem = (): OfferItem => ({ is_included_in_total: true, }) +function SortableItemRow({ item, index, currency, readOnly, canDelete, onUpdate, onRemove }: { + item: OfferItem; index: number; currency: string; readOnly: boolean; canDelete: boolean; + onUpdate: (field: string, value: unknown) => void; onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key, disabled: readOnly }) + 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 ( + + {!readOnly && ( + + + + )} + {index + 1} + + onUpdate('description', e.target.value)} + className="admin-form-input" placeholder="Název položky" readOnly={readOnly} /> + + + onUpdate('quantity', parseFloat(e.target.value) || 0)} + className="admin-form-input" step="1" readOnly={readOnly} /> + + + onUpdate('unit', e.target.value)} + className="admin-form-input" readOnly={readOnly} /> + + + onUpdate('unit_price', parseFloat(e.target.value) || 0)} + className="admin-form-input" step="0.01" readOnly={readOnly} /> + + + onUpdate('is_included_in_total', e.target.checked)} disabled={readOnly} /> + + + {formatCurrency(lineTotal, currency)} + + {!readOnly && ( + + + + )} + + ) +} + export default function OfferDetail() { const { id } = useParams() const isEdit = Boolean(id) @@ -104,7 +177,7 @@ export default function OfferDetail() { const [form, setForm] = useState(emptyForm) const [items, setItems] = useState([emptyItem()]) const [sections, setSections] = useState([]) - const [scopeTemplates, setScopeTemplates] = useState>([]) + const [scopeTemplates, setScopeTemplates] = useState }>>([]) const [customers, setCustomers] = useState([]) const [customerSearch, setCustomerSearch] = useState('') const [showCustomerDropdown, setShowCustomerDropdown] = useState(false) @@ -150,7 +223,7 @@ export default function OfferDetail() { scope_title: d.scope_title || '', scope_description: d.scope_description || '', }) - setItems(d.items?.length ? d.items : [emptyItem()]) + setItems(d.items?.length ? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) : [emptyItem()]) setSections(d.sections?.length ? d.sections.map((s: any) => ({ title: s.title || '', title_cz: s.title_cz || '', @@ -189,7 +262,7 @@ export default function OfferDetail() { if (res.status === 401) return const data = await res.json() if (data.success && Array.isArray(data.data)) { - setScopeTemplates(data.data.map((t: any) => ({ id: t.id, name: t.name }))) + setScopeTemplates(data.data) } } catch { /* silent */ } } @@ -734,7 +807,7 @@ export default function OfferDetail() { - {/* Items Section (simplified - no drag-and-drop) */} + {/* Items Section with drag-and-drop */} {errors.items}

}
- - - - - - - - - - - {!isInvalidated && - - - {items.map((item, index) => { - const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) - return ( - - - - - - - - - {!isInvalidated && ( - - )} + { + 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) + }) + }} + > + i._key)} strategy={verticalListSortingStrategy}> +
#PopisMnožstvíJednotkaCena/ksV ceněCelkem} -
{index + 1} - updateItem(index, 'description', e.target.value)} - className="admin-form-input" - placeholder="Název položky" - readOnly={isInvalidated} - /> - - updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} - className="admin-form-input" - step="1" - readOnly={isInvalidated} - /> - - updateItem(index, 'unit', e.target.value)} - className="admin-form-input" - readOnly={isInvalidated} - /> - - updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)} - className="admin-form-input" - step="0.01" - readOnly={isInvalidated} - /> - - updateItem(index, 'is_included_in_total', e.target.checked)} - disabled={isInvalidated} - /> - - {formatCurrency(lineTotal, form.currency)} - - -
+ + + {!isInvalidated && + + + + + + + {!isInvalidated && - ) - })} - -
} + #PopisMnožstvíJednotkaCena/ksV ceněCelkem}
+ + + {items.map((item, index) => ( + 1} + onUpdate={(field, value) => updateItem(index, field, value)} + onRemove={() => removeItem(index)} + /> + ))} + + + +
{/* Totals */} @@ -860,10 +896,6 @@ export default function OfferDetail() { {formatCurrency(total, form.currency)} - -

- Řazení položek přetažením bude dostupné v příští verzi. -

@@ -884,23 +916,21 @@ export default function OfferDetail() { className="admin-form-select" style={{ width: 'auto', minWidth: '160px' }} defaultValue="" - onChange={async (e) => { - const templateId = e.target.value + onChange={(e) => { + const templateId = Number(e.target.value) if (!templateId) return - try { - const res = await apiFetch(`${API_BASE}/offers-templates/${templateId}`) - const data = await res.json() - if (data.success && data.data.sections) { - const newSections = data.data.sections.map((s: any) => ({ - title: s.title || '', - title_cz: s.title_cz || '', - content: s.content || '', - })) - setSections(prev => [...prev, ...newSections]) - alert.success('Sekce ze šablony byly přidány') + const template = scopeTemplates.find(t => t.id === templateId) + if (template?.scope_template_sections?.length) { + const newSections = template.scope_template_sections.map((s: any) => ({ + title: s.title || '', + title_cz: s.title_cz || '', + content: s.content || '', + })) + setSections(prev => [...prev, ...newSections]) + if (template.description) { + setForm(prev => ({ ...prev, scope_description: template.description || prev.scope_description })) } - } catch { - alert.error('Nepodařilo se načíst šablonu') + alert.success(`Načtena šablona "${template.name}"`) } e.target.value = '' }}