Merge pull request 'ts issue' (#5) from el-ui into prod
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 46s

Reviewed-on: #5
This commit is contained in:
Brook-Tewabe-Yaltopia 2026-06-13 20:31:16 +03:00
commit 0989d6d963

View File

@ -7,37 +7,54 @@ import {
Search, Search,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Filter,
Terminal, Terminal,
} from "lucide-react"; } from "lucide-react";
import { auditService, type AuditLog } from "@/services"; import { auditService, type AuditLog } from "@/services";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function AuditPage() { export default function ActivityLogPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(15); const [limit] = useState(15);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"action" | "userId">("action");
const { data: auditData, isLoading } = useQuery({ const { data: auditData, isLoading } = useQuery({
queryKey: ["admin", "audit", "logs", page, limit, search], queryKey: ["activity-log", page, limit, search, filter],
queryFn: async () => { queryFn: async () => {
const params: Record<string, string | number> = { page, limit }; const params: Record<string, string | number> = {
if (search) params.search = search; page,
return await auditService.getAuditLogs(params); limit,
};
if (search.trim()) {
params.search = search.trim();
params.filter = filter;
}
return auditService.getAuditLogs(params);
}, },
}); });
const getActionColor = (action: string) => { const getActionColor = (action: string) => {
const act = action.toUpperCase(); const act = action.toUpperCase();
if (act.includes("CREATE"))
if (act.includes("CREATE")) {
return "text-blue-600 bg-blue-50 border-blue-100"; return "text-blue-600 bg-blue-50 border-blue-100";
if (act.includes("UPDATE")) }
if (act.includes("UPDATE")) {
return "text-emerald-600 bg-emerald-50 border-emerald-100"; return "text-emerald-600 bg-emerald-50 border-emerald-100";
if (act.includes("DELETE")) }
if (act.includes("DELETE")) {
return "text-rose-600 bg-rose-50 border-rose-100"; return "text-rose-600 bg-rose-50 border-rose-100";
if (act.includes("LOGIN")) }
if (act.includes("LOGIN")) {
return "text-purple-600 bg-purple-50 border-purple-100"; return "text-purple-600 bg-purple-50 border-purple-100";
}
return "text-slate-600 bg-slate-50 border-slate-100"; return "text-slate-600 bg-slate-50 border-slate-100";
}; };
@ -46,78 +63,88 @@ export default function AuditPage() {
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight"> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Audit Logs Activity Log
</h1> </h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
Comprehensive system transaction registry. Audit trail of all administrative actions.
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
{/* View only access: No administrative actions */}
</div>
</div> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0"> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Security Ledger System Audit
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative w-64"> <select
value={filter}
onChange={(e) => {
setPage(1);
setFilter(e.target.value as "action" | "userId");
}}
className="h-9 px-3 border border-gray-200 text-xs bg-white"
>
<option value="action">Action</option>
<option value="userId">User ID</option>
</select>
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input <Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs" className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search resources..." placeholder={`Search by ${filter}...`}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
/> />
</div> </div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full text-left">
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-50 border-b">
<tr> <tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Act Action
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User ID User
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Resource Detail
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
IP
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Date Timestamp
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td <td
colSpan={5} colSpan={4}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]" className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
> >
Retrieving audit trail... Synchronizing audit records...
</td> </td>
</tr> </tr>
) : auditData?.data && auditData.data.length > 0 ? ( ) : auditData?.data?.length ? (
auditData.data.map((log: AuditLog) => ( auditData.data.map((log: AuditLog) => (
<tr <tr
key={log.id} key={log.id}
className="hover:bg-gray-50 transition-colors group" className="hover:bg-gray-50 transition-colors"
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<span <span
@ -129,28 +156,45 @@ export default function AuditPage() {
{log.action} {log.action}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
{log.userId || "--"} <td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-gray-900">
{log.user
? `${log.user.firstName ?? ""} ${
log.user.lastName ?? ""
}`.trim() || "Unknown User"
: "SYSTEM"}
</p>
<p className="text-xs text-gray-500">
{log.user?.email ?? log.userId}
</p>
</div>
</td> </td>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "} <td className="px-6 py-4">
{log.resourceId.substring(0, 10)} <div className="flex items-center gap-2 text-sm text-gray-700">
<Terminal className="w-4 h-4 text-gray-400 shrink-0" />
<span>{log.detail}</span>
</div>
</td> </td>
<td className="px-6 py-4 text-xs font-mono text-gray-500">
{log.ipAddress || "--"} <td className="px-6 py-4 text-right text-xs text-gray-500 whitespace-nowrap">
</td> {format(
<td className="px-6 py-4 text-right text-xs text-gray-500 font-medium"> new Date(log.createdAt),
{format(new Date(log.timestamp), "MMM dd, HH:mm")} "MMM dd, yyyy HH:mm:ss",
)}
</td> </td>
</tr> </tr>
)) ))
) : ( ) : (
<tr> <tr>
<td <td
colSpan={5} colSpan={4}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]" className="px-6 py-20 text-center text-gray-400 italic"
> >
Security ledger is clear. No activity logs recorded.
</td> </td>
</tr> </tr>
)} )}
@ -158,27 +202,31 @@ export default function AuditPage() {
</table> </table>
</div> </div>
</CardContent> </CardContent>
{auditData && (
{auditData?.meta && auditData.meta.totalPages > 1 && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30"> <div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Standard View: {auditData.total || 0} Entries Page {auditData.meta.page} of {auditData.meta.totalPages} (
{auditData.meta.total} records)
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 rounded-none" className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1} disabled={!auditData.meta.hasPreviousPage}
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 rounded-none" className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
disabled={!auditData?.data || auditData.data.length < limit} disabled={!auditData.meta.hasNextPage}
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Button> </Button>
@ -188,4 +236,4 @@ export default function AuditPage() {
</Card> </Card>
</div> </div>
); );
} }