feat: mobilni responsivita, testy, klavesove zkratky, drag & drop, univerzalizace

- Mobile responsive CSS (touch targets 44px, iOS anti-zoom, reduced motion)
- Vitest setup s 39 testy (formatters, attendanceHelpers, useTableSort)
- Klavesove zkratky (Shift+? napoveda, Ctrl+S ulozit, navigace)
- Drag & drop pro polozky nabidek (@dnd-kit, SortableRow, useSortableList)
- Univerzalizace: odstraneni BOHA brandingu z UI, emailu, PDF
- Smazany nepotrebne soubory (deploy.sh, AUTH_SYSTEM.md, example_design, .htaccess)
- CORS konfigurovatelny pres env promennou

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 17:33:37 +01:00
parent 5ef6fc8064
commit bb2bbb8ff6
35 changed files with 2716 additions and 4392 deletions

View File

@@ -1,12 +1,23 @@
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 { formatCurrency } from '../utils/formatters'
import SortableRow, { DragHandle } from './SortableRow'
import useSortableList from '../hooks/useSortableList'
export default function OfferItemsSection({
items, updateItem, addItem, removeItem, moveItem,
items, setItems, updateItem, addItem, removeItem,
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
addItemFromTemplate, totals, currency, applyVat, vatRate,
itemsError, readOnly
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
useSensor(KeyboardSensor)
)
const { handleDragEnd } = useSortableList(setItems, '_key')
return (
<motion.div
className="offers-editor-section"
@@ -61,6 +72,7 @@ export default function OfferItemsSection({
<table className="admin-table">
<thead>
<tr>
{!readOnly && <th style={{ width: '2rem' }}></th>}
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis položky</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
@@ -68,107 +80,112 @@ export default function OfferItemsSection({
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
{!readOnly && <th style={{ width: '5.5rem', textAlign: 'center' }}></th>}
{!readOnly && <th style={{ width: '2.5rem', textAlign: 'center' }}></th>}
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key || index}>
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
className="admin-form-input"
placeholder="Název položky"
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
readOnly={readOnly}
/>
<input
type="text"
value={item.item_description}
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
className="admin-form-input"
placeholder="Podrobný popis (volitelný)"
style={{ fontSize: '0.8rem', opacity: 0.8 }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="1"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
className="admin-form-input"
placeholder="hod"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="0.01"
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td style={{ textAlign: 'center' }}>
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
<input
type="checkbox"
checked={item.is_included_in_total}
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
disabled={readOnly}
/>
<span></span>
</label>
</td>
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{formatCurrency(lineTotal, currency)}
</td>
{!readOnly && (
<td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<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>
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<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>
{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>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<SortableRow key={item._key} id={String(item._key)} disabled={readOnly}>
{({ attributes, listeners }) => (
<>
{!readOnly && (
<td style={{ width: '2rem' }}>
<DragHandle listeners={listeners} attributes={attributes} />
</td>
)}
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
className="admin-form-input"
placeholder="Název položky"
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
readOnly={readOnly}
/>
<input
type="text"
value={item.item_description}
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
className="admin-form-input"
placeholder="Podrobný popis (volitelný)"
style={{ fontSize: '0.8rem', opacity: 0.8 }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="1"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
className="admin-form-input"
placeholder="hod"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="0.01"
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td style={{ textAlign: 'center' }}>
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
<input
type="checkbox"
checked={item.is_included_in_total}
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
disabled={readOnly}
/>
<span></span>
</label>
</td>
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{formatCurrency(lineTotal, currency)}
</td>
{!readOnly && (
<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>
)}
</>
)}
</SortableRow>
)
})}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>