From 185157fe8643153c0d22a411927c3ddefc91300a Mon Sep 17 00:00:00 2001
From: BOHA
Date: Mon, 23 Mar 2026 19:02:15 +0100
Subject: [PATCH] feat: offer items drag-and-drop reordering + fix scope
template insertion
1. Item reordering: replaced placeholder with @dnd-kit drag-and-drop.
Each item row has a drag handle for reordering via vertical drag.
Uses SortableContext with verticalListSortingStrategy.
2. Scope template insertion: fixed template loading to use already-fetched
data instead of re-fetching from non-existent endpoint. Templates with
sections are now stored fully and inserted directly on selection.
Also copies template description to scope_description.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
package-lock.json | 71 +++++++++
package.json | 4 +
src/admin/pages/OfferDetail.tsx | 250 ++++++++++++++++++--------------
3 files changed, 215 insertions(+), 110 deletions(-)
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}
}
-
-
-
- | # |
- Popis |
- Množství |
- Jednotka |
- Cena/ks |
- V ceně |
- Celkem |
- {!isInvalidated && | }
-
-
-
- {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"
- 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 && (
-
-
- |
- )}
+ {
+ 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}>
+
+
+
+ {!isInvalidated && | }
+ # |
+ Popis |
+ Množství |
+ Jednotka |
+ Cena/ks |
+ V ceně |
+ Celkem |
+ {!isInvalidated && | }
- )
- })}
-
-
+
+
+ {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 = ''
}}