Compare commits
2 Commits
86d292f763
...
b2c2ab6e7a
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c2ab6e7a | |||
| adf202d421 |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,30 +146,67 @@ 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' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '100px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
|
||||||
|
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||||
|
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<div key={i} className="admin-skeleton-row">
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '120px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '80px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '80px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ flex: 1 }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '90px' }} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</div>
|
||||||
))
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="admin-page-header"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
<div className="admin-page-header">
|
<div>
|
||||||
<h1>Audit log</h1>
|
<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>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginBottom: '1rem' }}>
|
<motion.div
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
className="admin-card"
|
||||||
<div style={{ flex: '1 1 200px', minWidth: '150px' }}>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<label className="admin-form-label">Hledat</label>
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
@@ -174,55 +214,57 @@ export default function AuditLog() {
|
|||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
|
<FormField label="Akce">
|
||||||
<label className="admin-form-label">Akce</label>
|
|
||||||
<select
|
<select
|
||||||
className="admin-form-input"
|
className="admin-form-select"
|
||||||
value={filters.action}
|
value={filters.action}
|
||||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Všechny akce</option>
|
<option value="">Všechny</option>
|
||||||
{ACTION_OPTIONS.map(opt => (
|
{ACTION_OPTIONS.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</FormField>
|
||||||
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
|
<FormField label="Typ entity">
|
||||||
<label className="admin-form-label">Typ entity</label>
|
|
||||||
<select
|
<select
|
||||||
className="admin-form-input"
|
className="admin-form-select"
|
||||||
value={filters.entity_type}
|
value={filters.entity_type}
|
||||||
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
|
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Všechny typy</option>
|
<option value="">Všechny</option>
|
||||||
{ENTITY_OPTIONS.map(opt => (
|
{ENTITY_OPTIONS.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
|
<div className="admin-form-row" style={{ gridTemplateColumns: '1fr 1fr', maxWidth: '400px', marginTop: '0.75rem' }}>
|
||||||
<label className="admin-form-label">Od</label>
|
<FormField label="Od">
|
||||||
<input
|
<AdminDatePicker
|
||||||
type="date"
|
mode="date"
|
||||||
className="admin-form-input"
|
|
||||||
value={filters.date_from}
|
value={filters.date_from}
|
||||||
onChange={(e) => handleFilterChange('date_from', e.target.value)}
|
onChange={(val) => handleFilterChange('date_from', val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
|
<FormField label="Do">
|
||||||
<label className="admin-form-label">Do</label>
|
<AdminDatePicker
|
||||||
<input
|
mode="date"
|
||||||
type="date"
|
|
||||||
className="admin-form-input"
|
|
||||||
value={filters.date_to}
|
value={filters.date_to}
|
||||||
onChange={(e) => handleFilterChange('date_to', e.target.value)}
|
onChange={(val) => handleFilterChange('date_to', val)}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="admin-card">
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +653,37 @@ export default function InvoiceCreate() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="offers-items-table">
|
<div className="offers-items-table">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
<th style={{ width: '2rem' }}></th>
|
||||||
|
<th style={{ width: '2rem', textAlign: 'center' }}>#</th>
|
||||||
<th>Popis</th>
|
<th>Popis</th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||||
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
|
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
|
||||||
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}></th>
|
<th style={{ width: '2.5rem' }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
return (
|
return (
|
||||||
<tr key={item._key || index}>
|
<SortableRow key={item._key} id={String(item._key)}>
|
||||||
|
{({ attributes, listeners }) => (
|
||||||
|
<>
|
||||||
|
<td style={{ width: '2rem' }}>
|
||||||
|
<DragHandle listeners={listeners} attributes={attributes} />
|
||||||
|
</td>
|
||||||
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
@@ -729,13 +744,6 @@ export default function InvoiceCreate() {
|
|||||||
{formatCurrency(lineTotal, form.currency)}
|
{formatCurrency(lineTotal, form.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<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 && (
|
{items.length > 1 && (
|
||||||
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
<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">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -743,13 +751,16 @@ export default function InvoiceCreate() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</>
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Soucty */}
|
{/* Soucty */}
|
||||||
|
|||||||
Reference in New Issue
Block a user