feat: redesign /logs page with table UI and date range filter
- Replace timeline view with table layout (Time, Operator, Type, Message) - Add date range filter using @mantine/dates DatePickerInput - Add SegmentedControl for log type filter - Disable App Logs and Settings menu on /dev - Remove Activity Logs menu from /dev (moved to /logs) - Add dateFrom/dateTo query params to /api/logs backend - Import @mantine/dates/styles.css to fix datepicker styling Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -10,6 +10,7 @@
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"dayjs": "^1.11.20",
|
||||
"elkjs": "^0.9.3",
|
||||
"elysia": "^1.4.28",
|
||||
"minio": "^8.0.7",
|
||||
@@ -201,6 +203,8 @@
|
||||
|
||||
"@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="],
|
||||
|
||||
"@mantine/dates": ["@mantine/dates@9.1.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "9.1.1", "@mantine/hooks": "9.1.1", "dayjs": ">=1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
|
||||
"@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="],
|
||||
@@ -481,6 +485,8 @@
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
10
package.json
10
package.json
@@ -28,11 +28,16 @@
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@prisma/client": "6",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"dayjs": "^1.11.20",
|
||||
"elkjs": "^0.9.3",
|
||||
"elysia": "^1.4.28",
|
||||
"minio": "^8.0.7",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -42,10 +47,7 @@
|
||||
"react-dom": "^19",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"swr": "^2.4.1",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"elkjs": "^0.9.3"
|
||||
"swr": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
|
||||
13
src/app.ts
13
src/app.ts
@@ -414,6 +414,8 @@ export function createApp() {
|
||||
const search = query.search || ''
|
||||
const type = query.type as any
|
||||
const userId = query.userId
|
||||
const dateFrom = query.dateFrom
|
||||
const dateTo = query.dateTo
|
||||
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
@@ -428,6 +430,15 @@ export function createApp() {
|
||||
if (userId && userId !== 'all') {
|
||||
where.userId = userId
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {}
|
||||
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
|
||||
if (dateTo) {
|
||||
const end = new Date(dateTo)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
where.createdAt.lte = end
|
||||
}
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.log.findMany({
|
||||
@@ -452,6 +463,8 @@ export function createApp() {
|
||||
search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })),
|
||||
type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })),
|
||||
userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })),
|
||||
dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (ISO string atau YYYY-MM-DD)' })),
|
||||
dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (ISO string atau YYYY-MM-DD)' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Activity Logs',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/dates/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
import { ModalsProvider } from '@mantine/modals'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
|
||||
@@ -25,8 +25,12 @@ export const API_URLS = {
|
||||
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
|
||||
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
|
||||
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
|
||||
getGlobalLogs: (page: number, search: string, type: string, userId: string) =>
|
||||
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`,
|
||||
getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => {
|
||||
const params = new URLSearchParams({ page: String(page), search, type, userId })
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
return `/api/logs?${params}`
|
||||
},
|
||||
getLogOperators: () => `/api/logs/operators`,
|
||||
getOperators: (page: number, search: string) =>
|
||||
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
|
||||
|
||||
@@ -109,11 +109,11 @@ const navItems = [
|
||||
{ label: 'Overview', icon: TbLayoutDashboard, key: 'overview' },
|
||||
{ label: 'Operators', icon: TbUsers, key: 'operators' },
|
||||
{ label: 'Bugs', icon: TbBug, key: 'bugs' },
|
||||
{ label: 'App Logs', icon: TbServer, key: 'app-logs' },
|
||||
{ label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
|
||||
{ label: 'App Logs', icon: TbServer, key: 'app-logs', disabled: true },
|
||||
// { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
|
||||
{ label: 'Database', icon: TbDatabase, key: 'database' },
|
||||
{ label: 'Project', icon: TbSitemap, key: 'project' },
|
||||
{ label: 'Settings', icon: TbSettings, key: 'settings' },
|
||||
// { label: 'Settings', icon: TbSettings, key: 'settings' },
|
||||
]
|
||||
|
||||
function DevPage() {
|
||||
@@ -204,7 +204,8 @@ function DevPage() {
|
||||
variant={active === item.key ? 'filled' : 'subtle'}
|
||||
color={active === item.key ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={() => setActive(item.key)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => !item.disabled && setActive(item.key)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</ActionIcon>
|
||||
@@ -218,7 +219,8 @@ function DevPage() {
|
||||
leftSection={<Icon size={16} />}
|
||||
rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined}
|
||||
active={active === item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => !item.disabled && setActive(item.key)}
|
||||
style={{ borderRadius: 6 }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Paper,
|
||||
TextInput,
|
||||
Select,
|
||||
Avatar,
|
||||
Box,
|
||||
Divider,
|
||||
Loader,
|
||||
Pagination,
|
||||
Center,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/id'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
|
||||
import { TbRefresh } from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
@@ -25,263 +27,144 @@ export const Route = createFileRoute('/logs')({
|
||||
component: GlobalLogsPage,
|
||||
})
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then((r) => r.json())
|
||||
|
||||
const typeConfig: Record<string, { color: string; icon?: any }> = {
|
||||
CREATE: { color: 'blue', icon: TbCheck },
|
||||
UPDATE: { color: 'teal', icon: TbCheck },
|
||||
DELETE: { color: 'red', icon: TbX },
|
||||
LOGIN: { color: 'green', icon: TbClock },
|
||||
LOGOUT: { color: 'orange', icon: TbClock },
|
||||
}
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
const r = (role || '').toLowerCase()
|
||||
if (r.includes('super')) return 'red'
|
||||
if (r.includes('admin')) return 'brand-blue'
|
||||
if (r.includes('developer')) return 'violet'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function groupLogsByDate(logs: any[]) {
|
||||
const groups: Record<string, any[]> = {}
|
||||
|
||||
const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
|
||||
logs.forEach(log => {
|
||||
const dateObj = new Date(log.createdAt)
|
||||
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
|
||||
if (dateStr === today) dateStr = 'TODAY'
|
||||
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
|
||||
|
||||
if (!groups[dateStr]) groups[dateStr] = []
|
||||
|
||||
const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
groups[dateStr].push({
|
||||
id: log.id,
|
||||
time: timeStr,
|
||||
user: log.user,
|
||||
type: log.type,
|
||||
content: log.message,
|
||||
color: log.user ? getRoleColor(log.user.role) : 'gray',
|
||||
icon: typeConfig[log.type as string]?.icon
|
||||
})
|
||||
})
|
||||
|
||||
// We want to keep the order as they came from the API (sorted by createdAt desc)
|
||||
// but grouped by date. Object.entries might mess up the order if dates are not sequential.
|
||||
// However, since the source logs are sorted, the first encounter of a date defines the group order.
|
||||
const result: { date: string; logs: any[] }[] = []
|
||||
const seenDates = new Set<string>()
|
||||
|
||||
logs.forEach(log => {
|
||||
const dateObj = new Date(log.createdAt)
|
||||
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
if (dateStr === today) dateStr = 'TODAY'
|
||||
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
|
||||
|
||||
if (!seenDates.has(dateStr)) {
|
||||
result.push({ date: dateStr, logs: groups[dateStr] })
|
||||
seenDates.add(dateStr)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||
const LOG_TYPE_COLOR: Record<string, string> = {
|
||||
LOGIN: 'green',
|
||||
LOGOUT: 'gray',
|
||||
CREATE: 'blue',
|
||||
UPDATE: 'yellow',
|
||||
DELETE: 'red',
|
||||
}
|
||||
|
||||
function GlobalLogsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [logType, setLogType] = useState<string | null>('all')
|
||||
const [operatorId, setOperatorId] = useState<string | null>('all')
|
||||
const [type, setType] = useState('all')
|
||||
const [operatorId, setOperatorId] = useState('all')
|
||||
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
|
||||
|
||||
const operatorOptions = useMemo(() => {
|
||||
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
|
||||
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'Semua operator' }]
|
||||
return [
|
||||
{ value: 'all', label: 'All Operators' },
|
||||
...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
|
||||
{ value: 'all', label: 'Semua user' },
|
||||
...operatorsData.map((op: any) => ({ value: op.id, label: op.name })),
|
||||
]
|
||||
}, [operatorsData])
|
||||
|
||||
const { data: response, isLoading } = useSWR(
|
||||
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
|
||||
fetcher
|
||||
const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
|
||||
const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
|
||||
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
API_URLS.getGlobalLogs(page, '', type, operatorId, dateFrom, dateTo),
|
||||
fetcher,
|
||||
{ refreshInterval: 10_000 },
|
||||
)
|
||||
|
||||
const filteredTimeline = useMemo(() => {
|
||||
if (!response?.data) return []
|
||||
return groupLogsByDate(response.data)
|
||||
}, [response?.data])
|
||||
const logs: any[] = data?.data ?? []
|
||||
const totalPages: number = data?.totalPages ?? 1
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
|
||||
{/* Header Controls */}
|
||||
<Group mb="xl" gap="md">
|
||||
<TextInput
|
||||
placeholder="Search operator or message..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
w={250}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Log Type"
|
||||
data={[
|
||||
{ value: 'all', label: 'All Types' },
|
||||
{ value: 'CREATE', label: 'Create' },
|
||||
{ value: 'UPDATE', label: 'Update' },
|
||||
{ value: 'DELETE', label: 'Delete' },
|
||||
{ value: 'LOGIN', label: 'Login' },
|
||||
{ value: 'LOGOUT', label: 'Logout' },
|
||||
]}
|
||||
radius="md"
|
||||
w={160}
|
||||
value={logType}
|
||||
onChange={(val) => {
|
||||
setLogType(val)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Operator"
|
||||
data={operatorOptions}
|
||||
searchable
|
||||
radius="md"
|
||||
w={200}
|
||||
value={operatorId}
|
||||
onChange={(val) => {
|
||||
setOperatorId(val)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Activity Logs</Title>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => mutate()}>
|
||||
<TbRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Filter user"
|
||||
value={operatorId}
|
||||
onChange={(v) => { setOperatorId(v ?? 'all'); setPage(1) }}
|
||||
data={operatorOptions}
|
||||
w={180}
|
||||
clearable
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Filter tanggal"
|
||||
value={dateRange}
|
||||
onChange={(v) => { setDateRange(v); setPage(1) }}
|
||||
locale="id"
|
||||
valueFormat="DD MMM YYYY"
|
||||
clearable
|
||||
w={300}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={type}
|
||||
onChange={(v) => { setType(v); setPage(1) }}
|
||||
data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed">Loading logs...</Text>
|
||||
</Center>
|
||||
) : filteredTimeline.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : (
|
||||
<>
|
||||
{filteredTimeline.map((group, groupIndex) => (
|
||||
<Box key={group.date}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={groupIndex > 0 ? "xl" : 0}
|
||||
mb="md"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{group.date}
|
||||
</Text>
|
||||
|
||||
<Stack gap={0} pl={4}>
|
||||
{group.logs.map((log, logIndex) => {
|
||||
const isLastLog = logIndex === group.logs.length - 1;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={log.id}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
gap="lg"
|
||||
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
||||
>
|
||||
{/* Left: Time */}
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
w={70}
|
||||
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
||||
>
|
||||
{log.time}
|
||||
</Text>
|
||||
|
||||
{/* Middle: Line & Avatar */}
|
||||
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
||||
{/* Vertical Line */}
|
||||
{!isLastLog && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 24,
|
||||
bottom: -8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 1,
|
||||
backgroundColor: 'rgba(128,128,128,0.2)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar */}
|
||||
<Box style={{ position: 'relative', zIndex: 2 }}>
|
||||
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
|
||||
<Avatar
|
||||
size={24}
|
||||
radius="xl"
|
||||
color={log.color}
|
||||
variant="light"
|
||||
src={log.user?.image}
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right: Content */}
|
||||
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
||||
<Text size="sm">
|
||||
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
|
||||
{log.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{groupIndex < filteredTimeline.length - 1 && (
|
||||
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{response?.totalPages > 1 && (
|
||||
<Center mt="xl">
|
||||
<Pagination
|
||||
total={response.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
radius="md"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table striped highlightOnHover fz="xs" style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 160 }} />
|
||||
<col style={{ width: 200 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Time</Table.Th>
|
||||
<Table.Th>Operator</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Message</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{logs.map((log: any) => (
|
||||
<Table.Tr key={log.id}>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.createdAt).toLocaleString('id-ID')}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{log.user ? (
|
||||
<div>
|
||||
<Text fw={500} truncate>{log.user.name}</Text>
|
||||
<Text c="dimmed" truncate>{log.user.email}</Text>
|
||||
</div>
|
||||
) : <Text c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light">
|
||||
{log.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text>{log.message}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Center py="xl"><Text c="dimmed">Belum ada log aktivitas</Text></Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{totalPages > 1 && (
|
||||
<Center>
|
||||
<Pagination total={totalPages} value={page} onChange={setPage} size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user