- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones - Remove skeleton.css and @keyframes shimmer from base.css - Add <Skeleton> wrappers with fixtures to all 25+ page components - Generate 20 bone captures via boneyard CLI (CDP auth-gated capture) - Refactor data fetching from useEffect+useState to TanStack Query - Extract query hooks into src/admin/lib/queries/ and apiAdapter - Add usePaginatedQuery hook replacing useApiCall/useListData - Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs - Fix customer_id mandatory validation on offer creation - Fix leave-requests comma-separated status filter (Prisma enum in: []) - Add cross-entity cache invalidation for orders/offers/invoices/projects - Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.) - Add boneyard.config.json with routes and breakpoints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
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";
|
|
import { Skeleton } from "boneyard-js/react";
|
|
import AuditLogFixture from "../fixtures/AuditLogFixture";
|
|
|
|
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 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 queryClient = useQueryClient();
|
|
const [filters, setFilters] = useState<Filters>({
|
|
search: "",
|
|
action: "",
|
|
entity_type: "",
|
|
date_from: "",
|
|
date_to: "",
|
|
});
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(50);
|
|
const [showCleanup, setShowCleanup] = useState(false);
|
|
const [cleanupDays, setCleanupDays] = useState(90);
|
|
const [cleaning, setCleaning] = useState(false);
|
|
|
|
const { data: logsData, isPending } = useQuery({
|
|
queryKey: [
|
|
"audit-log",
|
|
{
|
|
search: filters.search,
|
|
action: filters.action,
|
|
entityType: filters.entity_type,
|
|
dateFrom: filters.date_from,
|
|
dateTo: filters.date_to,
|
|
page,
|
|
perPage,
|
|
},
|
|
],
|
|
queryFn: async () => {
|
|
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()}`,
|
|
);
|
|
if (response.status === 401) throw new Error("Unauthorized");
|
|
const result = await response.json();
|
|
if (!result.success)
|
|
throw new Error(result.error || "Nepodařilo se načíst audit log");
|
|
return {
|
|
data: Array.isArray(result.data) ? result.data : [],
|
|
pagination: {
|
|
total: result.pagination?.total ?? 0,
|
|
page: result.pagination?.page ?? 1,
|
|
per_page: result.pagination?.limit ?? perPage,
|
|
total_pages: result.pagination?.total_pages ?? 1,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const logs = logsData?.data ?? [];
|
|
const pagination = logsData?.pagination ?? null;
|
|
|
|
if (!hasPermission("settings.audit")) {
|
|
return <Forbidden />;
|
|
}
|
|
|
|
const handleFilterChange = (key: keyof Filters, value: string) => {
|
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
setPage(1);
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
setPage(newPage);
|
|
};
|
|
|
|
const handlePerPageChange = (newPerPage: number) => {
|
|
setPage(1);
|
|
setPerPage(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);
|
|
queryClient.invalidateQueries({ queryKey: ["audit-log"] });
|
|
} 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 (isPending && logs.length === 0) {
|
|
return (
|
|
<Skeleton
|
|
name="audit-log"
|
|
loading={isPending && logs.length === 0}
|
|
fixture={<AuditLogFixture />}
|
|
>
|
|
<div />
|
|
</Skeleton>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<Skeleton
|
|
name="audit-log-rows"
|
|
loading={isPending}
|
|
fixture={
|
|
<div style={{ padding: "1rem" }}>
|
|
{Array.from({ length: 10 }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
display: "flex",
|
|
gap: "1rem",
|
|
marginBottom: "0.75rem",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 110,
|
|
height: 14,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 4,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: 80,
|
|
height: 14,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 4,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: 70,
|
|
height: 22,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 10,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: 80,
|
|
height: 14,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 4,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
height: 14,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 4,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: 90,
|
|
height: 14,
|
|
background: "var(--bg-tertiary)",
|
|
borderRadius: 4,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
<>
|
|
{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>
|
|
)}
|
|
{logs.length > 0 &&
|
|
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>
|
|
))}
|
|
</>
|
|
</Skeleton>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination
|
|
pagination={pagination}
|
|
onPageChange={handlePageChange}
|
|
onPerPageChange={handlePerPageChange}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|