refactor: sjednoceni zdroje casu na MySQL NOW() + audit log cleanup a UI

- attendance handlery pouzivaji getDbNow() misto PHP date()
- nova helper funkce getDbNow() v AttendanceHelpers.php
- audit log: cleanup endpoint (POST) s volbou stari zaznamu
- audit log: filtry na jednom radku
- dashboard: aktivita prejmenovana na Audit log s odkazem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 20:48:05 +01:00
parent 36a864c852
commit 5529219234
5 changed files with 157 additions and 17 deletions

View File

@@ -26,12 +26,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit; exit;
} }
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'], true)) {
errorResponse('Method not allowed', 405); errorResponse('Method not allowed', 405);
} }
requirePermission($authData, 'settings.audit'); requirePermission($authData, 'settings.audit');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = getJsonInput();
$action = $input['action'] ?? '';
if ($action !== 'cleanup') {
errorResponse('Neplatná akce');
}
$days = (int) ($input['days'] ?? 90);
$pdo = db();
if ($days === 0) {
$stmt = $pdo->query('DELETE FROM audit_logs');
$deleted = $stmt->rowCount();
$msg = $deleted > 0
? "Smazáno všech $deleted záznamů"
: 'Audit log je prázdný';
} else {
$days = max(1, $days);
$stmt = $pdo->prepare(
'DELETE FROM audit_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)'
);
$stmt->execute([$days]);
$deleted = $stmt->rowCount();
$msg = $deleted > 0
? "Smazáno $deleted záznamů starších $days dní"
: "Žádné záznamy starší než $days dní nebyly nalezeny";
}
successResponse(['deleted' => $deleted], $msg);
}
$page = max(1, (int) ($_GET['page'] ?? 1)); $page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50))); $perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
function handleGetCurrent(PDO $pdo, int $userId): void function handleGetCurrent(PDO $pdo, int $userId): void
{ {
$today = date('Y-m-d'); $dbTime = getDbNow($pdo);
$today = $dbTime['today'];
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng, SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
@@ -68,13 +69,13 @@ function handleGetCurrent(PDO $pdo, int $userId): void
$leaveBalance = getLeaveBalance($pdo, $userId); $leaveBalance = getLeaveBalance($pdo, $userId);
$currentYear = (int)date('Y'); $currentYear = $dbTime['year'];
$currentMonth = (int)date('m'); $currentMonth = $dbTime['month'];
$fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth); $fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth);
$businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth); $businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth);
$startDate = date('Y-m-01'); $startDate = substr($dbTime['today'], 0, 7) . '-01';
$endDate = date('Y-m-t'); $endDate = date('Y-m-t', strtotime($startDate));
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT id, user_id, shift_date, arrival_time, break_start, break_end, SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
@@ -253,8 +254,9 @@ function handlePunch(PDO $pdo, int $userId): void
{ {
$input = getJsonInput(); $input = getJsonInput();
$action = $input['punch_action'] ?? ''; $action = $input['punch_action'] ?? '';
$today = date('Y-m-d'); $dbTime = getDbNow($pdo);
$rawNow = date('Y-m-d H:i:s'); $today = $dbTime['today'];
$rawNow = $dbTime['now'];
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null; $lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null; $lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
@@ -508,7 +510,7 @@ function handleSwitchProject(PDO $pdo, int $userId): void
} }
$attendanceId = $currentShift['id']; $attendanceId = $currentShift['id'];
$now = date('Y-m-d H:i:s'); $now = getDbNow($pdo)['now'];
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'UPDATE attendance_project_logs SET ended_at = ? 'UPDATE attendance_project_logs SET ended_at = ?

View File

@@ -6,6 +6,21 @@
declare(strict_types=1); declare(strict_types=1);
/**
* Vraci aktualni cas a datum z MySQL (jednotny zdroj casu)
* @return array{now: string, today: string, year: int, month: int}
*/
function getDbNow(PDO $pdo): array
{
$row = $pdo->query("SELECT NOW() AS now, CURDATE() AS today, YEAR(NOW()) AS y, MONTH(NOW()) AS m")->fetch();
return [
'now' => $row['now'],
'today' => $row['today'],
'year' => (int)$row['y'],
'month' => (int)$row['m'],
];
}
function roundUpTo15Minutes(string $datetime): string function roundUpTo15Minutes(string $datetime): string
{ {
$timestamp = strtotime($datetime); $timestamp = strtotime($datetime);
@@ -327,15 +342,14 @@ function addFundDataToUserTotals(PDO $pdo, array &$userTotals, int $year, int $m
} }
unset($ut); unset($ut);
$today = date('Y-m-d');
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT DISTINCT user_id FROM attendance SELECT DISTINCT user_id FROM attendance
WHERE shift_date = ? WHERE shift_date = CURDATE()
AND arrival_time IS NOT NULL AND arrival_time IS NOT NULL
AND departure_time IS NULL AND departure_time IS NULL
AND (leave_type IS NULL OR leave_type = 'work') AND (leave_type IS NULL OR leave_type = 'work')
"); ");
$stmt->execute([$today]); $stmt->execute();
$workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN); $workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($workingNow as $uid) { foreach ($workingNow as $uid) {
if (isset($userTotals[$uid])) { if (isset($userTotals[$uid])) {

View File

@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers' import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
function getActivityIcon(action) { function getActivityIcon(action) {
@@ -43,8 +44,9 @@ export default function DashActivityFeed({ activities }) {
return ( return (
<div className="admin-card dash-activity-card"> <div className="admin-card dash-activity-card">
<div className="admin-card-header"> <div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 className="admin-card-title">Poslední aktivita</h2> <h2 className="admin-card-title">Audit log</h2>
<Link to="/audit-log" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
</div> </div>
<div className="admin-card-body" style={{ padding: 0 }}> <div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => ( {activities.map((act) => (

View File

@@ -76,6 +76,9 @@ export default function AuditLog() {
date_from: '', date_from: '',
date_to: '', 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) => { const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
setLoading(true) setLoading(true)
@@ -139,6 +142,29 @@ export default function AuditLog() {
fetchLogs(1, newPerPage) fetchLogs(1, newPerPage)
} }
const handleCleanup = async () => {
setCleaning(true)
try {
const response = await apiFetch(`${API_BASE}/audit-log.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cleanup', 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) => { const formatDatetime = (dateString) => {
if (!dateString) { if (!dateString) {
return '-' return '-'
@@ -195,8 +221,74 @@ export default function AuditLog() {
</p> </p>
)} )}
</div> </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> </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 <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -205,7 +297,7 @@ export default function AuditLog() {
style={{ marginBottom: '1rem' }} style={{ marginBottom: '1rem' }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-form-row" style={{ gridTemplateColumns: '2fr 1fr 1fr' }}> <div className="admin-form-row" style={{ gridTemplateColumns: '1.2fr 1fr 1fr 1fr 1fr' }}>
<FormField label="Hledat"> <FormField label="Hledat">
<input <input
type="text" type="text"
@@ -239,8 +331,6 @@ export default function AuditLog() {
))} ))}
</select> </select>
</FormField> </FormField>
</div>
<div className="admin-form-row" style={{ gridTemplateColumns: '1fr 1fr', maxWidth: '400px', marginTop: '0.75rem' }}>
<FormField label="Od"> <FormField label="Od">
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"