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

@@ -2148,3 +2148,200 @@ img {
transform: translateY(-1px);
}
/* ============================================================================
Mobile Responsive Enhancements
============================================================================ */
/* Touch targets - min 44px na mobilech */
@media (max-width: 768px) {
.admin-btn {
min-height: 44px;
padding: 10px 16px;
}
.admin-btn-sm {
min-height: 36px;
}
.admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
.admin-form-input,
.admin-form-select,
.admin-form-textarea {
min-height: 44px;
font-size: 16px; /* zabrání auto-zoomu na iOS */
}
.admin-form-checkbox {
min-height: 44px;
padding: 8px 0;
}
.admin-form-checkbox input + span::before {
width: 20px;
height: 20px;
}
.admin-form-label {
font-size: 13px;
}
}
/* Tabulky - kompaktnejsi na mobilech, lepsi scroll indikace */
@media (max-width: 640px) {
.admin-table-wrapper,
.admin-table-responsive {
margin: 0 -1rem;
padding: 0 1rem;
position: relative;
}
.admin-table-wrapper::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 24px;
background: linear-gradient(to right, transparent, var(--bg-primary));
pointer-events: none;
opacity: 0.8;
}
.admin-table {
min-width: 500px;
}
.admin-table th,
.admin-table td {
padding: 8px;
font-size: 11px;
}
.admin-table th {
font-size: 9px;
}
.admin-table-actions {
gap: 0.25rem;
}
}
/* Page header na mobilech */
@media (max-width: 480px) {
.admin-page-title {
font-size: 18px;
}
.admin-page-subtitle {
font-size: 12px;
}
.admin-content {
padding: 12px !important;
}
.admin-card-body {
padding: 12px;
}
.admin-card-header {
padding: 12px;
}
}
/* Grid - single column na malych mobilech */
@media (max-width: 480px) {
.admin-grid-4 {
grid-template-columns: 1fr;
}
}
/* Confirm modal - ne fullscreen na mobilech */
@media (max-width: 480px) {
.admin-confirm-content {
padding: 1.5rem 1rem;
}
.admin-confirm-title {
font-size: 1.1rem;
}
.admin-confirm-message {
font-size: 0.875rem;
}
}
/* Skeleton loading na mobilech */
@media (max-width: 640px) {
.admin-skeleton {
border-radius: 4px;
}
}
/* Badge na mobilech - vetsi pro touch */
@media (max-width: 768px) {
.admin-badge {
padding: 4px 10px;
font-size: 12px;
}
button.admin-badge {
min-height: 32px;
}
}
/* Prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Drag handle */
.admin-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-muted);
cursor: grab;
border-radius: 4px;
padding: 0;
transition: color 0.15s, background 0.15s;
touch-action: none;
}
.admin-drag-handle:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.admin-drag-handle:active {
cursor: grabbing;
}
/* Keyboard shortcut badge */
.admin-kbd {
display: inline-block;
padding: 2px 7px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
border-radius: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
white-space: nowrap;
}

View File

@@ -6,6 +6,7 @@ import { useTheme } from '../../context/ThemeContext'
import { setLogoutAlert } from '../utils/api'
import useModalLock from '../hooks/useModalLock'
import Sidebar from './Sidebar'
import ShortcutsHelp from './ShortcutsHelp'
export default function AdminLayout() {
const { isAuthenticated, loading, checkSession, user, logout } = useAuth()
@@ -101,6 +102,7 @@ export default function AdminLayout() {
<Outlet />
</main>
</div>
<ShortcutsHelp />
</motion.div>
)
}

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>

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
import useKeyboardShortcuts from '../hooks/useKeyboardShortcuts'
const GLOBAL_SHORTCUTS = [
{ keys: '?', description: 'Zobrazit klávesové zkratky' },
{ keys: 'Ctrl + N', description: 'Nový záznam' },
{ keys: 'Ctrl + S', description: 'Uložit' },
{ keys: 'Escape', description: 'Zavřít modal / zrušit' },
{ keys: '/', description: 'Hledat' },
]
export default function ShortcutsHelp() {
const [open, setOpen] = useState(false)
useKeyboardShortcuts([
{ key: '?', shift: true, handler: () => setOpen(prev => !prev) },
{ key: 'Escape', handler: () => setOpen(false), when: open },
])
if (!open) return null
return (
<div className="admin-modal-overlay" onClick={() => setOpen(false)}>
<div className="admin-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="admin-modal-header">
<h3>Klávesové zkratky</h3>
<button className="admin-modal-close" onClick={() => setOpen(false)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div className="admin-modal-body">
<table className="admin-table" style={{ minWidth: 'auto' }}>
<tbody>
{GLOBAL_SHORTCUTS.map(s => (
<tr key={s.keys}>
<td style={{ width: 120 }}>
<kbd className="admin-kbd">{s.keys}</kbd>
</td>
<td>{s.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
export function DragHandle({ listeners, attributes }) {
return (
<button
type="button"
className="admin-drag-handle"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout pro změnu pořadí"
>
<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>
)
}
export default function SortableRow({ id, children, disabled }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: 'relative',
zIndex: isDragging ? 10 : undefined,
background: isDragging ? 'var(--bg-secondary)' : undefined,
}
return (
<tr ref={setNodeRef} style={style}>
{typeof children === 'function'
? children({ attributes, listeners })
: children
}
</tr>
)
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import useTableSort from '../useTableSort'
describe('useTableSort', () => {
it('vraci pocatecni stav s default hodnotami', () => {
const { result } = renderHook(() => useTableSort('name'))
expect(result.current.sort).toBe('name')
expect(result.current.order).toBe('DESC')
expect(result.current.activeSort).toBeNull()
})
it('respektuje custom pocatecni order', () => {
const { result } = renderHook(() => useTableSort('date', 'ASC'))
expect(result.current.sort).toBe('date')
expect(result.current.order).toBe('ASC')
})
it('activeSort je null dokud uzivatel neklikne', () => {
const { result } = renderHook(() => useTableSort('name'))
expect(result.current.activeSort).toBeNull()
})
it('po kliknuti na sloupec nastavi activeSort', () => {
const { result } = renderHook(() => useTableSort('name'))
act(() => {
result.current.handleSort('name')
})
expect(result.current.activeSort).toBe('name')
})
it('toggleuje order pri kliknuti na stejny sloupec', () => {
const { result } = renderHook(() => useTableSort('name'))
act(() => {
result.current.handleSort('name')
})
// Default DESC -> toggle to ASC
expect(result.current.order).toBe('ASC')
act(() => {
result.current.handleSort('name')
})
expect(result.current.order).toBe('DESC')
})
it('pri kliknuti na jiny sloupec resetuje order na DESC', () => {
const { result } = renderHook(() => useTableSort('name'))
// Klikneme na name - toggle z DESC na ASC
act(() => {
result.current.handleSort('name')
})
expect(result.current.order).toBe('ASC')
// Klikneme na jiny sloupec - reset na DESC
act(() => {
result.current.handleSort('date')
})
expect(result.current.sort).toBe('date')
expect(result.current.order).toBe('DESC')
})
})

View File

@@ -0,0 +1,36 @@
import { useEffect, useCallback } from 'react'
/**
* Hook pro globalni keyboard shortcuts
* @param {Array<{key: string, ctrl?: boolean, shift?: boolean, alt?: boolean, handler: Function, when?: boolean}>} shortcuts
*/
export default function useKeyboardShortcuts(shortcuts) {
const handleKeyDown = useCallback((e) => {
// Ignorovat pokud je focus v inputu/textarea/contenteditable (krome Escape)
const tag = e.target.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable
for (const shortcut of shortcuts) {
if (shortcut.when === false) continue
const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !(e.ctrlKey || e.metaKey)
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey
const altMatch = shortcut.alt ? e.altKey : !e.altKey
const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase()
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
// Escape funguje i v inputech
if (isInput && e.key !== 'Escape') continue
e.preventDefault()
shortcut.handler(e)
return
}
}
}, [shortcuts])
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
}

View File

@@ -0,0 +1,29 @@
import { useCallback } from 'react'
import { arrayMove } from '@dnd-kit/sortable'
/**
* Hook pro drag-and-drop razeni seznamu
* Vraci handleDragEnd pro DndContext
*
* @param {Function} setItems - setter pro pole polozek
* @param {string} keyField - nazev property pro unikatni identifikaci (_key, id)
*/
export default function useSortableList(setItems, keyField = '_key') {
const handleDragEnd = useCallback((event) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
setItems(prev => {
const oldIndex = prev.findIndex(item => String(item[keyField]) === String(active.id))
const newIndex = prev.findIndex(item => String(item[keyField]) === String(over.id))
if (oldIndex === -1 || newIndex === -1) {
return prev
}
return arrayMove(prev, oldIndex, newIndex)
})
}, [setItems, keyField])
return { handleDragEnd }
}

View File

@@ -124,3 +124,18 @@
align-items: flex-start;
}
@media (max-width: 640px) {
.invoice-month-btn {
width: 44px;
height: 44px;
}
.received-upload-row {
flex-direction: column;
}
.received-upload-file-name {
max-width: 200px;
}
}

View File

@@ -158,7 +158,7 @@ export default function Login() {
<div className="admin-login-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
alt="BOHA Automation"
alt="Logo"
className="admin-login-logo"
/>
<h1 className="admin-login-title">Interní systém</h1>

View File

@@ -330,16 +330,6 @@ export default function OfferDetail() {
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}
const moveItem = (index, direction) => {
setItems(prev => {
const newItems = [...prev]
const target = index + direction
if (target < 0 || target >= newItems.length) return prev
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]]
return newItems
})
}
const addItemFromTemplate = (template) => {
setItems(prev => [...prev, {
_key: `item-${++_keyCounter}`,
@@ -921,10 +911,10 @@ export default function OfferDetail() {
<OfferItemsSection
items={items}
setItems={setItems}
updateItem={updateItem}
addItem={addItem}
removeItem={removeItem}
moveItem={moveItem}
itemTemplates={itemTemplates}
showItemTemplateMenu={showItemTemplateMenu}
setShowItemTemplateMenu={setShowItemTemplateMenu}

View File

@@ -52,3 +52,13 @@
line-height: 1.4;
padding-left: 2.75rem;
}
@media (max-width: 768px) {
.admin-permission-item {
padding: 0.75rem;
}
.admin-permission-item .admin-form-checkbox {
min-height: 44px;
}
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest'
import {
formatDate,
formatTime,
formatDatetime,
formatMinutes,
calculateWorkMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass
} from '../attendanceHelpers'
describe('formatDate', () => {
it('formatuje datum do cs-CZ formatu', () => {
const result = formatDate('2026-03-12')
expect(result).toMatch(/12/)
expect(result).toMatch(/2026/)
})
it('vraci pomlcku pro falsy hodnoty', () => {
expect(formatDate('')).toBe('—')
expect(formatDate(null)).toBe('—')
expect(formatDate(undefined)).toBe('—')
})
})
describe('formatTime', () => {
it('formatuje cas ve formatu HH:MM', () => {
const result = formatTime('2026-03-12T14:30:00')
expect(result).toBe('14:30')
})
it('vraci pomlcku pro prazdny vstup', () => {
expect(formatTime('')).toBe('—')
expect(formatTime(null)).toBe('—')
})
})
describe('formatDatetime', () => {
it('formatuje datum a cas dohromady', () => {
const result = formatDatetime('2026-03-12T14:30:00')
expect(result).toContain('12')
expect(result).toContain('14:30')
})
it('vraci pomlcku pro prazdny vstup', () => {
expect(formatDatetime('')).toBe('—')
expect(formatDatetime(null)).toBe('—')
})
})
describe('formatMinutes', () => {
it('formatuje minuty na H:MM', () => {
expect(formatMinutes(90)).toBe('1:30')
expect(formatMinutes(60)).toBe('1:00')
expect(formatMinutes(0)).toBe('0:00')
expect(formatMinutes(125)).toBe('2:05')
})
it('formatuje s jednotkou kdyz withUnit=true', () => {
expect(formatMinutes(90, true)).toBe('1:30 h')
expect(formatMinutes(0, true)).toBe('0:00 h')
})
it('formatuje male hodnoty s paddingem', () => {
expect(formatMinutes(5)).toBe('0:05')
expect(formatMinutes(1)).toBe('0:01')
})
})
describe('calculateWorkMinutes', () => {
it('spocita minuty bez prestavky', () => {
const record = {
arrival_time: '2026-03-12T08:00:00',
departure_time: '2026-03-12T16:00:00'
}
expect(calculateWorkMinutes(record)).toBe(480)
})
it('odecte prestavku', () => {
const record = {
arrival_time: '2026-03-12T08:00:00',
departure_time: '2026-03-12T16:00:00',
break_start: '2026-03-12T12:00:00',
break_end: '2026-03-12T12:30:00'
}
expect(calculateWorkMinutes(record)).toBe(450)
})
it('vraci 0 kdyz chybi prichod nebo odchod', () => {
expect(calculateWorkMinutes({ arrival_time: '2026-03-12T08:00:00' })).toBe(0)
expect(calculateWorkMinutes({ departure_time: '2026-03-12T16:00:00' })).toBe(0)
expect(calculateWorkMinutes({})).toBe(0)
})
it('vraci 0 pro zaporne minuty', () => {
const record = {
arrival_time: '2026-03-12T16:00:00',
departure_time: '2026-03-12T08:00:00'
}
expect(calculateWorkMinutes(record)).toBe(0)
})
})
describe('getLeaveTypeName', () => {
it('vraci spravne nazvy pro zname typy', () => {
expect(getLeaveTypeName('work')).toBe('Práce')
expect(getLeaveTypeName('vacation')).toBe('Dovolená')
expect(getLeaveTypeName('sick')).toBe('Nemoc')
expect(getLeaveTypeName('holiday')).toBe('Svátek')
expect(getLeaveTypeName('unpaid')).toBe('Neplacené volno')
})
it('vraci Prace jako fallback', () => {
expect(getLeaveTypeName('unknown')).toBe('Práce')
expect(getLeaveTypeName(undefined)).toBe('Práce')
})
})
describe('getLeaveTypeBadgeClass', () => {
it('vraci spravne CSS tridy', () => {
expect(getLeaveTypeBadgeClass('vacation')).toBe('badge-vacation')
expect(getLeaveTypeBadgeClass('sick')).toBe('badge-sick')
expect(getLeaveTypeBadgeClass('holiday')).toBe('badge-holiday')
expect(getLeaveTypeBadgeClass('unpaid')).toBe('badge-unpaid')
})
it('vraci prazdny retezec pro work a nezname typy', () => {
expect(getLeaveTypeBadgeClass('work')).toBe('')
expect(getLeaveTypeBadgeClass('unknown')).toBe('')
expect(getLeaveTypeBadgeClass(undefined)).toBe('')
})
})

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate, formatKm, czechPlural } from '../formatters'
describe('formatCurrency', () => {
it('formatuje CZK s dvema desetinnymi misty', () => {
const result = formatCurrency(1234.5, 'CZK')
expect(result).toContain('Kč')
expect(result).toMatch(/1[\s\u00a0]?234,50/)
})
it('formatuje EUR s eurem za castkou', () => {
const result = formatCurrency(99.9, 'EUR')
expect(result).toContain('€')
expect(result).toContain('99,90')
})
it('formatuje USD s dolarem pred castkou', () => {
const result = formatCurrency(1500, 'USD')
expect(result).toMatch(/^\$/)
expect(result).toContain('1,500.00')
})
it('formatuje GBP s librou pred castkou', () => {
const result = formatCurrency(250, 'GBP')
expect(result).toMatch(/^£/)
expect(result).toContain('250.00')
})
it('pouzije fallback pro neznámou menu', () => {
const result = formatCurrency(100, 'CHF')
expect(result).toBe('100.00 CHF')
})
it('vraci 0 pro nevalidni vstup', () => {
const result = formatCurrency('abc', 'CZK')
expect(result).toContain('0,00')
expect(result).toContain('Kč')
})
it('vraci 0 pro null', () => {
const result = formatCurrency(null, 'CZK')
expect(result).toContain('0,00')
})
it('formatuje zaporne castky', () => {
const result = formatCurrency(-500, 'CZK')
expect(result).toContain('Kč')
expect(result).toContain('500')
})
})
describe('formatDate', () => {
it('formatuje datum do cs-CZ formatu', () => {
const result = formatDate('2026-03-12')
// cs-CZ format: 12. 3. 2026 nebo 12.3.2026
expect(result).toMatch(/12/)
expect(result).toMatch(/3/)
expect(result).toMatch(/2026/)
})
it('vraci pomlcku pro prazdny vstup', () => {
expect(formatDate('')).toBe('—')
expect(formatDate(null)).toBe('—')
expect(formatDate(undefined)).toBe('—')
})
})
describe('formatKm', () => {
it('formatuje kilometry s oddelovacem tisicu', () => {
const result = formatKm(12345)
// cs-CZ pouziva mezeru nebo narrow no-break space jako oddelovac
expect(result).toMatch(/12[\s\u00a0]?345/)
})
it('vraci 0 pro nevalidni vstup', () => {
expect(formatKm('abc')).toBe('0')
expect(formatKm(null)).toBe('0')
})
it('formatuje mala cisla bez oddelovace', () => {
expect(formatKm(42)).toBe('42')
})
})
describe('czechPlural', () => {
it('vraci tvar pro 1', () => {
expect(czechPlural(1, 'den', 'dny', 'dní')).toBe('den')
})
it('vraci tvar pro 2-4', () => {
expect(czechPlural(2, 'den', 'dny', 'dní')).toBe('dny')
expect(czechPlural(3, 'den', 'dny', 'dní')).toBe('dny')
expect(czechPlural(4, 'den', 'dny', 'dní')).toBe('dny')
})
it('vraci tvar pro 0 a 5+', () => {
expect(czechPlural(0, 'den', 'dny', 'dní')).toBe('dní')
expect(czechPlural(5, 'den', 'dny', 'dní')).toBe('dní')
expect(czechPlural(10, 'den', 'dny', 'dní')).toBe('dní')
expect(czechPlural(100, 'den', 'dny', 'dní')).toBe('dní')
})
})