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 ( +
| # | -Popis | -Množství | -Jednotka | -Cena/ks | -V ceně | -Celkem | - {!isInvalidated &&} - | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {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 && ( -- - | - )} +
| } + | # | +Popis | +Množství | +Jednotka | +Cena/ks | +V ceně | +Celkem | + {!isInvalidated &&} |
|---|
- Řazení položek přetažením bude dostupné v příští verzi. -