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) <noreply@anthropic.com>
This commit is contained in:
71
package-lock.json
generated
71
package-lock.json
generated
@@ -9,6 +9,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"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/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
@@ -53,6 +57,73 @@
|
|||||||
"vitest": "^4.1.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"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/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ 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 } from '@dnd-kit/modifiers'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
import FormField from '../components/FormField'
|
import FormField from '../components/FormField'
|
||||||
import Forbidden from '../components/Forbidden'
|
import Forbidden from '../components/Forbidden'
|
||||||
@@ -18,6 +22,7 @@ const API_BASE = '/api/admin'
|
|||||||
const DRAFT_KEY = 'boha_offer_draft'
|
const DRAFT_KEY = 'boha_offer_draft'
|
||||||
|
|
||||||
interface OfferItem {
|
interface OfferItem {
|
||||||
|
_key: string
|
||||||
id?: number
|
id?: number
|
||||||
description: string
|
description: string
|
||||||
item_description: string
|
item_description: string
|
||||||
@@ -27,6 +32,9 @@ interface OfferItem {
|
|||||||
is_included_in_total: boolean
|
is_included_in_total: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _itemKeyCounter = 0
|
||||||
|
const nextItemKey = () => `item-${++_itemKeyCounter}`
|
||||||
|
|
||||||
interface ScopeSection {
|
interface ScopeSection {
|
||||||
title: string
|
title: string
|
||||||
title_cz: string
|
title_cz: string
|
||||||
@@ -83,6 +91,7 @@ const emptyScopeSection = (): ScopeSection => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emptyItem = (): OfferItem => ({
|
const emptyItem = (): OfferItem => ({
|
||||||
|
_key: nextItemKey(),
|
||||||
description: '',
|
description: '',
|
||||||
item_description: '',
|
item_description: '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
@@ -91,6 +100,70 @@ const emptyItem = (): OfferItem => ({
|
|||||||
is_included_in_total: true,
|
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 (
|
||||||
|
<tr ref={setNodeRef} style={style}>
|
||||||
|
{!readOnly && (
|
||||||
|
<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 style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>{index + 1}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" value={item.description} onChange={e => onUpdate('description', e.target.value)}
|
||||||
|
className="admin-form-input" placeholder="Název položky" readOnly={readOnly} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" value={item.quantity} onChange={e => onUpdate('quantity', parseFloat(e.target.value) || 0)}
|
||||||
|
className="admin-form-input" step="1" readOnly={readOnly} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" value={item.unit} onChange={e => onUpdate('unit', e.target.value)}
|
||||||
|
className="admin-form-input" readOnly={readOnly} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" value={item.unit_price} onChange={e => onUpdate('unit_price', parseFloat(e.target.value) || 0)}
|
||||||
|
className="admin-form-input" step="0.01" readOnly={readOnly} />
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
<input type="checkbox" checked={item.is_included_in_total}
|
||||||
|
onChange={e => onUpdate('is_included_in_total', e.target.checked)} disabled={readOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>
|
||||||
|
{formatCurrency(lineTotal, currency)}
|
||||||
|
</td>
|
||||||
|
{!readOnly && (
|
||||||
|
<td>
|
||||||
|
<button onClick={onRemove} className="admin-btn-icon danger" title="Odebrat" disabled={!canDelete}>
|
||||||
|
<svg width="16" height="16" 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 OfferDetail() {
|
export default function OfferDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
@@ -104,7 +177,7 @@ export default function OfferDetail() {
|
|||||||
const [form, setForm] = useState<OfferForm>(emptyForm)
|
const [form, setForm] = useState<OfferForm>(emptyForm)
|
||||||
const [items, setItems] = useState<OfferItem[]>([emptyItem()])
|
const [items, setItems] = useState<OfferItem[]>([emptyItem()])
|
||||||
const [sections, setSections] = useState<ScopeSection[]>([])
|
const [sections, setSections] = useState<ScopeSection[]>([])
|
||||||
const [scopeTemplates, setScopeTemplates] = useState<Array<{ id: number; name: string }>>([])
|
const [scopeTemplates, setScopeTemplates] = useState<Array<{ id: number; name: string; description?: string; scope_template_sections?: Array<{ title?: string; title_cz?: string; content?: string }> }>>([])
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [customerSearch, setCustomerSearch] = useState('')
|
const [customerSearch, setCustomerSearch] = useState('')
|
||||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
||||||
@@ -150,7 +223,7 @@ export default function OfferDetail() {
|
|||||||
scope_title: d.scope_title || '',
|
scope_title: d.scope_title || '',
|
||||||
scope_description: d.scope_description || '',
|
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) => ({
|
setSections(d.sections?.length ? d.sections.map((s: any) => ({
|
||||||
title: s.title || '',
|
title: s.title || '',
|
||||||
title_cz: s.title_cz || '',
|
title_cz: s.title_cz || '',
|
||||||
@@ -189,7 +262,7 @@ export default function OfferDetail() {
|
|||||||
if (res.status === 401) return
|
if (res.status === 401) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success && Array.isArray(data.data)) {
|
if (data.success && Array.isArray(data.data)) {
|
||||||
setScopeTemplates(data.data.map((t: any) => ({ id: t.id, name: t.name })))
|
setScopeTemplates(data.data)
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -734,7 +807,7 @@ export default function OfferDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Items Section (simplified - no drag-and-drop) */}
|
{/* Items Section with drag-and-drop */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -753,9 +826,30 @@ export default function OfferDetail() {
|
|||||||
{errors.items && <p style={{ color: 'var(--color-danger)', fontSize: '0.85rem' }}>{errors.items}</p>}
|
{errors.items && <p style={{ color: 'var(--color-danger)', fontSize: '0.85rem' }}>{errors.items}</p>}
|
||||||
|
|
||||||
<div className="admin-table-responsive">
|
<div className="admin-table-responsive">
|
||||||
|
<DndContext
|
||||||
|
sensors={useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
)}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
onDragEnd={(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)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={items.map(i => i._key)} strategy={verticalListSortingStrategy}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{!isInvalidated && <th style={{ width: '2rem' }} />}
|
||||||
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
||||||
<th>Popis</th>
|
<th>Popis</th>
|
||||||
<th style={{ width: '5rem' }}>Množství</th>
|
<th style={{ width: '5rem' }}>Množství</th>
|
||||||
@@ -767,80 +861,22 @@ export default function OfferDetail() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => (
|
||||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
<SortableItemRow
|
||||||
return (
|
key={item._key}
|
||||||
<tr key={index}>
|
item={item}
|
||||||
<td style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>{index + 1}</td>
|
index={index}
|
||||||
<td>
|
currency={form.currency}
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Název položky"
|
|
||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated}
|
||||||
|
canDelete={items.length > 1}
|
||||||
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
/>
|
/>
|
||||||
</td>
|
))}
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
|
||||||
className="admin-form-input"
|
|
||||||
step="1"
|
|
||||||
readOnly={isInvalidated}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.unit}
|
|
||||||
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
|
||||||
className="admin-form-input"
|
|
||||||
readOnly={isInvalidated}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.unit_price}
|
|
||||||
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
|
||||||
className="admin-form-input"
|
|
||||||
step="0.01"
|
|
||||||
readOnly={isInvalidated}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: 'center' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={item.is_included_in_total}
|
|
||||||
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
|
|
||||||
disabled={isInvalidated}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>
|
|
||||||
{formatCurrency(lineTotal, form.currency)}
|
|
||||||
</td>
|
|
||||||
{!isInvalidated && (
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => removeItem(index)}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Odebrat"
|
|
||||||
disabled={items.length <= 1}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" 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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
@@ -860,10 +896,6 @@ export default function OfferDetail() {
|
|||||||
<span>{formatCurrency(total, form.currency)}</span>
|
<span>{formatCurrency(total, form.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.8rem', marginTop: '1rem' }}>
|
|
||||||
Řazení položek přetažením bude dostupné v příští verzi.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -884,23 +916,21 @@ export default function OfferDetail() {
|
|||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
style={{ width: 'auto', minWidth: '160px' }}
|
style={{ width: 'auto', minWidth: '160px' }}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onChange={async (e) => {
|
onChange={(e) => {
|
||||||
const templateId = e.target.value
|
const templateId = Number(e.target.value)
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
try {
|
const template = scopeTemplates.find(t => t.id === templateId)
|
||||||
const res = await apiFetch(`${API_BASE}/offers-templates/${templateId}`)
|
if (template?.scope_template_sections?.length) {
|
||||||
const data = await res.json()
|
const newSections = template.scope_template_sections.map((s: any) => ({
|
||||||
if (data.success && data.data.sections) {
|
|
||||||
const newSections = data.data.sections.map((s: any) => ({
|
|
||||||
title: s.title || '',
|
title: s.title || '',
|
||||||
title_cz: s.title_cz || '',
|
title_cz: s.title_cz || '',
|
||||||
content: s.content || '',
|
content: s.content || '',
|
||||||
}))
|
}))
|
||||||
setSections(prev => [...prev, ...newSections])
|
setSections(prev => [...prev, ...newSections])
|
||||||
alert.success('Sekce ze šablony byly přidány')
|
if (template.description) {
|
||||||
|
setForm(prev => ({ ...prev, scope_description: template.description || prev.scope_description }))
|
||||||
}
|
}
|
||||||
} catch {
|
alert.success(`Načtena šablona "${template.name}"`)
|
||||||
alert.error('Nepodařilo se načíst šablonu')
|
|
||||||
}
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user