initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View 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>
)
}