feat(activity-log): Implement dynamic audit log page with filtering and export

This commit is contained in:
debudebuye 2026-02-24 19:01:31 +03:00
parent a1c9b689d5
commit ba209593f5
3 changed files with 166 additions and 95 deletions

View File

@ -1,3 +1,5 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -10,14 +12,60 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, MoreVertical } from "lucide-react"
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
import { auditService } from "@/services"
import { format } from "date-fns"
export default function ActivityLogPage() {
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [search, setSearch] = useState("")
const [actionFilter, setActionFilter] = useState("")
const [resourceTypeFilter, setResourceTypeFilter] = useState("")
const { data: auditData, isLoading } = useQuery({
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
queryFn: async () => {
const params: any = { page, limit }
if (search) params.search = search
if (actionFilter) params.action = actionFilter
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
return await auditService.getAuditLogs(params)
},
})
const handleExport = async () => {
try {
const blob = await auditService.exportAuditLogs({ format: 'csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export failed:', error)
}
}
const getActionBadgeColor = (action: string) => {
const colors: Record<string, string> = {
Create: "bg-blue-500",
Update: "bg-green-500",
Delete: "bg-red-500",
Login: "bg-purple-500",
Logout: "bg-gray-500",
}
return colors[action] || "bg-gray-500"
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Activity Log</h2>
<Button>
<Button onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export Log
</Button>
@ -33,109 +81,113 @@ export default function ActivityLogPage() {
<Input
placeholder="Search activity..."
className="pl-10 w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Actions</option>
<option>Create</option>
<option>Update</option>
<option>Delete</option>
<option>Login</option>
<option>Logout</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
>
<option value="">All Actions</option>
<option value="Create">Create</option>
<option value="Update">Update</option>
<option value="Delete">Delete</option>
<option value="Login">Login</option>
<option value="Logout">Logout</option>
</select>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Users</option>
<option>Admin</option>
<option>Manager</option>
<option>User</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={resourceTypeFilter}
onChange={(e) => setResourceTypeFilter(e.target.value)}
>
<option value="">All Resources</option>
<option value="Client">Client</option>
<option value="Subscription">Subscription</option>
<option value="User">User</option>
<option value="System">System</option>
</select>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Log ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Description</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">LOG001</TableCell>
<TableCell>john.smith@example.com</TableCell>
<TableCell>
<Badge className="bg-blue-500">Create</Badge>
</TableCell>
<TableCell>Client</TableCell>
<TableCell>Created new client record</TableCell>
<TableCell>192.168.1.1</TableCell>
<TableCell>2024-01-15 10:30:45</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
{isLoading ? (
<div className="text-center py-8">Loading activity logs...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Log ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Resource ID</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditData?.data?.map((log: any) => (
<TableRow key={log.id}>
<TableCell className="font-medium">{log.id}</TableCell>
<TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell>
{format(new Date(log.timestamp || log.createdAt), 'MMM dd, yyyy HH:mm:ss')}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{auditData?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No activity logs found
</div>
)}
{auditData && auditData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Page {auditData.page} of {auditData.totalPages} ({auditData.total} total)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))}
disabled={page === auditData.totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">LOG002</TableCell>
<TableCell>jane.doe@example.com</TableCell>
<TableCell>
<Badge className="bg-green-500">Update</Badge>
</TableCell>
<TableCell>Subscription</TableCell>
<TableCell>Updated subscription status</TableCell>
<TableCell>192.168.1.2</TableCell>
<TableCell>2024-01-15 09:15:22</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">LOG003</TableCell>
<TableCell>admin@example.com</TableCell>
<TableCell>
<Badge className="bg-purple-500">Login</Badge>
</TableCell>
<TableCell>System</TableCell>
<TableCell>User logged in successfully</TableCell>
<TableCell>192.168.1.3</TableCell>
<TableCell>2024-01-15 08:00:00</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>

View File

@ -10,6 +10,18 @@ const apiClient: AxiosInstance = axios.create({
},
withCredentials: true, // Send cookies with requests
timeout: 30000, // 30 second timeout
paramsSerializer: {
serialize: (params) => {
// Custom serializer to preserve number types
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
}
})
return searchParams.toString()
}
}
})
// Request interceptor - Add auth token

View File

@ -41,7 +41,14 @@ class UserService {
* Get paginated list of users
*/
async getUsers(params?: GetUsersParams): Promise<PaginatedResponse<User>> {
const response = await apiClient.get<PaginatedResponse<User>>('/admin/users', { params })
// Ensure numeric params are sent as numbers, not strings
const queryParams = params ? {
...params,
page: params.page ? Number(params.page) : undefined,
limit: params.limit ? Number(params.limit) : undefined,
} : undefined
const response = await apiClient.get<PaginatedResponse<User>>('/admin/users', { params: queryParams })
return response.data
}