Compare commits

..

2 Commits

Author SHA1 Message Date
b2c2ab6e7a refactor: redesign AuditLog stranky + pagination styling
- AuditLog: admin-page-header/title pattern, FormField filtry, AdminDatePicker,
  skeleton loading, empty state, admin-badge-danger pro destructivni akce
- Pagination CSS: border-top oddeleni, padding sjednocen
- Novy admin-badge-danger styl (cerveny)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:11:31 +01:00
adf202d421 feat: drag & drop razeni polozek pri vytvareni faktury
Nahrazeny tlacitka nahoru/dolu za @dnd-kit drag & drop (SortableRow + DragHandle),
stejny pattern jako v nabidkach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:51:00 +01:00
3 changed files with 271 additions and 192 deletions

View File

@@ -1484,6 +1484,11 @@ img {
color: var(--info); color: var(--info);
} }
.admin-badge-danger {
background: var(--danger-soft);
color: var(--danger);
}
/* ============================================================================ /* ============================================================================
Modals Modals
============================================================================ */ ============================================================================ */
@@ -2342,7 +2347,9 @@ img {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 0; padding: 0.75rem 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border-color);
font-size: 13px; font-size: 13px;
} }

View File

@@ -4,6 +4,9 @@ import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext' import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden' import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination' import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { czechPlural } from '../utils/formatters'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
@@ -26,16 +29,16 @@ const ACTION_LABELS = {
const ACTION_BADGE_CLASS = { const ACTION_BADGE_CLASS = {
create: 'admin-badge-success', create: 'admin-badge-success',
update: 'admin-badge-info', update: 'admin-badge-info',
delete: 'admin-badge-warning', delete: 'admin-badge-danger',
login: 'admin-badge-secondary', login: 'admin-badge-secondary',
login_failed: 'admin-badge-warning', login_failed: 'admin-badge-danger',
logout: 'admin-badge-secondary', logout: 'admin-badge-secondary',
view: 'admin-badge-info', view: 'admin-badge-info',
activate: 'admin-badge-success', activate: 'admin-badge-success',
deactivate: 'admin-badge-warning', deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info', password_change: 'admin-badge-info',
permission_change: 'admin-badge-info', permission_change: 'admin-badge-warning',
access_denied: 'admin-badge-warning', access_denied: 'admin-badge-danger',
} }
const ENTITY_TYPE_LABELS = { const ENTITY_TYPE_LABELS = {
@@ -143,86 +146,125 @@ export default function AuditLog() {
return new Date(dateString).toLocaleString('cs-CZ') return new Date(dateString).toLocaleString('cs-CZ')
} }
const renderSkeletonRows = () => ( if (loading && logs.length === 0) {
Array.from({ length: 10 }, (_, i) => ( return (
<tr key={`skeleton-${i}`}> <div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
{Array.from({ length: 6 }, (_, j) => ( <div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<td key={j}><div className="admin-skeleton" style={{ height: '16px', width: `${60 + Math.random() * 40}%` }} /></td> <div>
))} <div className="admin-skeleton-line h-8" style={{ width: '160px', marginBottom: '0.5rem' }} />
</tr> <div className="admin-skeleton-line" style={{ width: '100px' }} />
))
)
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-page-header">
<h1>Audit log</h1>
</div>
<div className="admin-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 200px', minWidth: '150px' }}>
<label className="admin-form-label">Hledat</label>
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</div> </div>
<div style={{ flex: '0 1 180px', minWidth: '140px' }}> </div>
<label className="admin-form-label">Akce</label> <div className="admin-card">
<select <div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
className="admin-form-input" <div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
>
<option value="">Všechny akce</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div> </div>
<div style={{ flex: '0 1 180px', minWidth: '140px' }}> </div>
<label className="admin-form-label">Typ entity</label> <div className="admin-card">
<select <div className="admin-skeleton" style={{ gap: '1rem' }}>
className="admin-form-input" <div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
value={filters.entity_type} {Array.from({ length: 8 }, (_, i) => (
onChange={(e) => handleFilterChange('entity_type', e.target.value)} <div key={i} className="admin-skeleton-row">
> <div className="admin-skeleton-line" style={{ width: '120px' }} />
<option value="">Všechny typy</option> <div className="admin-skeleton-line" style={{ width: '80px' }} />
{ENTITY_OPTIONS.map(opt => ( <div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
<option key={opt.value} value={opt.value}>{opt.label}</option> <div className="admin-skeleton-line" style={{ width: '80px' }} />
))} <div className="admin-skeleton-line" style={{ flex: 1 }} />
</select> <div className="admin-skeleton-line" style={{ width: '90px' }} />
</div> </div>
<div style={{ flex: '0 1 160px', minWidth: '130px' }}> ))}
<label className="admin-form-label">Od</label>
<input
type="date"
className="admin-form-input"
value={filters.date_from}
onChange={(e) => handleFilterChange('date_from', e.target.value)}
/>
</div>
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
<label className="admin-form-label">Do</label>
<input
type="date"
className="admin-form-input"
value={filters.date_to}
onChange={(e) => handleFilterChange('date_to', e.target.value)}
/>
</div> </div>
</div> </div>
</div> </div>
)
}
<div className="admin-card"> return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
</p>
)}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
style={{ marginBottom: '1rem' }}
>
<div className="admin-card-body">
<div className="admin-form-row" style={{ gridTemplateColumns: '2fr 1fr 1fr' }}>
<FormField label="Hledat">
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Typ entity">
<select
className="admin-form-select"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
</div>
<div className="admin-form-row" style={{ gridTemplateColumns: '1fr 1fr', maxWidth: '400px', marginTop: '0.75rem' }}>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={filters.date_from}
onChange={(val) => handleFilterChange('date_from', val)}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={filters.date_to}
onChange={(val) => handleFilterChange('date_to', val)}
/>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-table-wrapper"> <div className="admin-table-wrapper">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -236,17 +278,36 @@ export default function AuditLog() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && renderSkeletonRows()} {loading && Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td><div className="admin-skeleton-line" style={{ width: '110px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '60%', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '90px', height: '14px' }} /></td>
</tr>
))}
{!loading && logs.length === 0 && ( {!loading && logs.length === 0 && (
<tr> <tr>
<td colSpan="6" style={{ textAlign: 'center', padding: '2rem' }}> <td colSpan="6">
Žádné záznamy k zobrazení <div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td> </td>
</tr> </tr>
)} )}
{!loading && logs.map((log) => ( {!loading && logs.map((log) => (
<tr key={log.id}> <tr key={log.id}>
<td className="admin-mono">{formatDatetime(log.created_at)}</td> <td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>{formatDatetime(log.created_at)}</td>
<td style={{ fontWeight: 500 }}>{log.username || '-'}</td> <td style={{ fontWeight: 500 }}>{log.username || '-'}</td>
<td> <td>
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}> <span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
@@ -267,7 +328,7 @@ export default function AuditLog() {
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange} onPerPageChange={handlePerPageChange}
/> />
</div> </motion.div>
</motion.div> </div>
) )
} }

View File

@@ -6,6 +6,11 @@ import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField' import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from '../components/AdminDatePicker'
import { motion } from 'framer-motion' 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 SortableRow, { DragHandle } from '../components/SortableRow'
import useSortableList from '../hooks/useSortableList'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters' import { formatCurrency } from '../utils/formatters'
@@ -315,15 +320,12 @@ export default function InvoiceCreate() {
setItems(prev => prev.filter((_, i) => i !== index)) setItems(prev => prev.filter((_, i) => i !== index))
} }
const moveItem = (index, direction) => { const sensors = useSensors(
setItems(prev => { useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
const newItems = [...prev] useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
const target = index + direction useSensor(KeyboardSensor)
if (target < 0 || target >= newItems.length) return prev )
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]] const { handleDragEnd } = useSortableList(setItems, '_key')
return newItems
})
}
// Totals // Totals
const totals = useMemo(() => { const totals = useMemo(() => {
@@ -651,105 +653,114 @@ export default function InvoiceCreate() {
</div> </div>
<div className="offers-items-table"> <div className="offers-items-table">
<table className="admin-table"> <DndContext
<thead> sensors={sensors}
<tr> collisionDetection={closestCenter}
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th> modifiers={[restrictToVerticalAxis]}
<th>Popis</th> onDragEnd={handleDragEnd}
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th> >
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th> <table className="admin-table">
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th> <thead>
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null} <tr>
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th> <th style={{ width: '2rem' }}></th>
<th style={{ width: '5.5rem', textAlign: 'center' }}></th> <th style={{ width: '2rem', textAlign: 'center' }}>#</th>
</tr> <th>Popis</th>
</thead> <th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<tbody> <th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
{items.map((item, index) => { <th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) {form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
return ( <th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
<tr key={item._key || index}> <th style={{ width: '2.5rem' }}></th>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td> </tr>
<td> </thead>
<input <SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
type="text" <tbody>
value={item.description} {items.map((item, index) => {
onChange={(e) => updateItem(index, 'description', e.target.value)} const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
className="admin-form-input" return (
placeholder="Popis položky..." <SortableRow key={item._key} id={String(item._key)}>
style={{ fontWeight: 500 }} {({ attributes, listeners }) => (
/> <>
</td> <td style={{ width: '2rem' }}>
<td> <DragHandle listeners={listeners} attributes={attributes} />
<input </td>
type="number" <td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
value={item.quantity} <td>
onChange={(e) => updateItem(index, 'quantity', e.target.value)} <input
className="admin-form-input" type="text"
min="0" value={item.description}
step="any" onChange={(e) => updateItem(index, 'description', e.target.value)}
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} className="admin-form-input"
/> placeholder="Popis položky..."
</td> style={{ fontWeight: 500 }}
<td> />
<input </td>
type="text" <td>
value={item.unit} <input
onChange={(e) => updateItem(index, 'unit', e.target.value)} type="number"
className="admin-form-input" value={item.quantity}
placeholder="ks" onChange={(e) => updateItem(index, 'quantity', e.target.value)}
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} className="admin-form-input"
/> min="0"
</td> step="any"
<td> style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
<input />
type="number" </td>
value={item.unit_price} <td>
onChange={(e) => updateItem(index, 'unit_price', e.target.value)} <input
className="admin-form-input" type="text"
step="any" value={item.unit}
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} onChange={(e) => updateItem(index, 'unit', e.target.value)}
/> className="admin-form-input"
</td> placeholder="ks"
{form.apply_vat ? ( style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
<td> />
<select </td>
value={item.vat_rate} <td>
onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))} <input
className="admin-form-input" type="number"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} value={item.unit_price}
> onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
{VAT_OPTIONS.map(o => ( className="admin-form-input"
<option key={o.value} value={o.value}>{o.label}</option> step="any"
))} style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
</select> />
</td> </td>
) : null} {form.apply_vat ? (
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}> <td>
{formatCurrency(lineTotal, form.currency)} <select
</td> value={item.vat_rate}
<td> onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))}
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}> className="admin-form-input"
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru"> style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
<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> {VAT_OPTIONS.map(o => (
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů"> <option key={o.value} value={o.value}>{o.label}</option>
<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> </select>
{items.length > 1 && ( </td>
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat"> ) : null}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> {formatCurrency(lineTotal, form.currency)}
</svg> </td>
</button> <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>
</>
)} )}
</div> </SortableRow>
</td> )
</tr> })}
) </tbody>
})} </SortableContext>
</tbody> </table>
</table> </DndContext>
</div> </div>
{/* Soucty */} {/* Soucty */}