initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
437
src/admin/pages/AuditLog.tsx
Normal file
437
src/admin/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
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'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Vytvoření',
|
||||
update: 'Úprava',
|
||||
delete: 'Smazání',
|
||||
login: 'Přihlášení',
|
||||
login_failed: 'Neúspěšné přihlášení',
|
||||
logout: 'Odhlášení',
|
||||
view: 'Zobrazení',
|
||||
activate: 'Aktivace',
|
||||
deactivate: 'Deaktivace',
|
||||
password_change: 'Změna hesla',
|
||||
permission_change: 'Změna oprávnění',
|
||||
access_denied: 'Přístup odepřen',
|
||||
}
|
||||
|
||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
||||
create: 'admin-badge-success',
|
||||
update: 'admin-badge-info',
|
||||
delete: 'admin-badge-danger',
|
||||
login: 'admin-badge-secondary',
|
||||
login_failed: 'admin-badge-danger',
|
||||
logout: 'admin-badge-secondary',
|
||||
view: 'admin-badge-info',
|
||||
activate: 'admin-badge-success',
|
||||
deactivate: 'admin-badge-warning',
|
||||
password_change: 'admin-badge-info',
|
||||
permission_change: 'admin-badge-warning',
|
||||
access_denied: 'admin-badge-danger',
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
user: 'Uživatel',
|
||||
attendance: 'Docházka',
|
||||
leave_request: 'Žádost o nepřítomnost',
|
||||
offers_quotation: 'Nabídka',
|
||||
offers_customer: 'Zákazník',
|
||||
offers_item_template: 'Šablona položky',
|
||||
offers_scope_template: 'Šablona rozsahu',
|
||||
offers_settings: 'Nastavení nabídek',
|
||||
orders_order: 'Objednávka',
|
||||
invoices_invoice: 'Faktura',
|
||||
projects_project: 'Projekt',
|
||||
role: 'Role',
|
||||
trips: 'Jízda',
|
||||
vehicles: 'Vozidlo',
|
||||
bank_account: 'Bankovní účet',
|
||||
}
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
|
||||
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number
|
||||
created_at: string
|
||||
username: string | null
|
||||
action: string
|
||||
entity_type: string | null
|
||||
description: string | null
|
||||
user_ip: string | null
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string
|
||||
action: string
|
||||
entity_type: string
|
||||
date_from: string
|
||||
date_to: string
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null)
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
search: '',
|
||||
action: '',
|
||||
entity_type: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
const [showCleanup, setShowCleanup] = useState(false)
|
||||
const [cleanupDays, setCleanupDays] = useState(90)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
|
||||
if (filters.search) params.set('search', filters.search)
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
if (filters.entity_type) params.set('entity_type', filters.entity_type)
|
||||
if (filters.date_from) params.set('date_from', filters.date_from)
|
||||
if (filters.date_to) params.set('date_to', filters.date_to)
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/audit-log?${params.toString()}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setLogs(Array.isArray(data.data) ? data.data : [])
|
||||
setPagination({
|
||||
total: data.pagination?.total ?? 0,
|
||||
page: data.pagination?.page ?? 1,
|
||||
per_page: data.pagination?.limit ?? 50,
|
||||
total_pages: data.pagination?.total_pages ?? 1,
|
||||
})
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se načíst audit log')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters, alert])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [fetchLogs])
|
||||
|
||||
if (!hasPermission('settings.audit')) {
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLogs(newPage, pagination?.per_page || 50)
|
||||
}
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
fetchLogs(1, newPerPage)
|
||||
}
|
||||
|
||||
const handleCleanup = async () => {
|
||||
setCleaning(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days: cleanupDays }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
alert.success(data.message)
|
||||
setShowCleanup(false)
|
||||
fetchLogs()
|
||||
} else {
|
||||
alert.error(data.error)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDatetime = (dateString: string | null): string => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('cs-CZ')
|
||||
}
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<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 flex-1" />
|
||||
<div className="admin-skeleton-line" style={{ width: '90px' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Vyčistit
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{showCleanup && (
|
||||
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
|
||||
<div className="admin-modal-backdrop" onClick={() => !cleaning && setShowCleanup(false)} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-body admin-confirm-content">
|
||||
<div className="admin-confirm-icon admin-confirm-icon-danger">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
|
||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
||||
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={cleanupDays}
|
||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={30}>30 dní</option>
|
||||
<option value={60}>60 dní</option>
|
||||
<option value={90}>90 dní</option>
|
||||
<option value={180}>180 dní</option>
|
||||
<option value={365}>1 rok</option>
|
||||
<option value={0}>Vše</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="admin-confirm-message" style={{ fontSize: '12px', opacity: 0.6 }}>Tato akce je nevratná.</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCleanup(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={cleaning}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCleanup}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={cleaning}
|
||||
>
|
||||
{cleaning ? 'Mažu...' : 'Smazat'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="admin-card mb-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row admin-form-row-5">
|
||||
<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>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_from}
|
||||
onChange={(val: string) => handleFilterChange('date_from', val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_to}
|
||||
onChange={(val: string) => handleFilterChange('date_to', val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Čas</th>
|
||||
<th>Uživatel</th>
|
||||
<th>Akce</th>
|
||||
<th>Typ entity</th>
|
||||
<th>Popis</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<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>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
|
||||
<td className="fw-500">{log.username || '-'}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>{ENTITY_TYPE_LABELS[log.entity_type || ''] || log.entity_type || '-'}</td>
|
||||
<td>{log.description || '-'}</td>
|
||||
<td className="admin-mono">{log.user_ip || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user