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:
2026-04-29 11:27:05 +08:00
parent 6b6e3f3430
commit 3c6fac1943
7 changed files with 164 additions and 253 deletions

View File

@@ -10,6 +10,7 @@
"@elysiajs/swagger": "^1.3.1", "@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18", "@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^8.3.18",
@@ -17,6 +18,7 @@
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"@xyflow/react": "^12.6.4", "@xyflow/react": "^12.6.4",
"dayjs": "^1.11.20",
"elkjs": "^0.9.3", "elkjs": "^0.9.3",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"minio": "^8.0.7", "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/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/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=="], "@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=="], "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=="], "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=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],

View File

@@ -28,11 +28,16 @@
"@elysiajs/swagger": "^1.3.1", "@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^8.3.18",
"@prisma/client": "6", "@prisma/client": "6",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"@xyflow/react": "^12.6.4",
"dayjs": "^1.11.20",
"elkjs": "^0.9.3",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"minio": "^8.0.7", "minio": "^8.0.7",
"postcss": "^8.5.8", "postcss": "^8.5.8",
@@ -42,10 +47,7 @@
"react-dom": "^19", "react-dom": "^19",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"swr": "^2.4.1", "swr": "^2.4.1"
"@mantine/modals": "^8.3.18",
"@xyflow/react": "^12.6.4",
"elkjs": "^0.9.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",

View File

@@ -414,6 +414,8 @@ export function createApp() {
const search = query.search || '' const search = query.search || ''
const type = query.type as any const type = query.type as any
const userId = query.userId const userId = query.userId
const dateFrom = query.dateFrom
const dateTo = query.dateTo
const where: any = {} const where: any = {}
if (search) { if (search) {
@@ -428,6 +430,15 @@ export function createApp() {
if (userId && userId !== 'all') { if (userId && userId !== 'all') {
where.userId = userId 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([ const [logs, total] = await Promise.all([
prisma.log.findMany({ prisma.log.findMany({
@@ -452,6 +463,8 @@ export function createApp() {
search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })), 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' })), 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"' })), 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: { detail: {
summary: 'List Activity Logs', summary: 'List Activity Logs',

View File

@@ -1,5 +1,6 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core' import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
import '@mantine/dates/styles.css'
import '@mantine/notifications/styles.css' import '@mantine/notifications/styles.css'
import { ModalsProvider } from '@mantine/modals' import { ModalsProvider } from '@mantine/modals'
import { Notifications } from '@mantine/notifications' import { Notifications } from '@mantine/notifications'

View File

@@ -25,8 +25,12 @@ export const API_URLS = {
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
getGlobalLogs: (page: number, search: string, type: string, userId: string) => getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => {
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`, 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`, getLogOperators: () => `/api/logs/operators`,
getOperators: (page: number, search: string) => getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`, `/api/operators?page=${page}&search=${encodeURIComponent(search)}`,

View File

@@ -109,11 +109,11 @@ const navItems = [
{ label: 'Overview', icon: TbLayoutDashboard, key: 'overview' }, { label: 'Overview', icon: TbLayoutDashboard, key: 'overview' },
{ label: 'Operators', icon: TbUsers, key: 'operators' }, { label: 'Operators', icon: TbUsers, key: 'operators' },
{ label: 'Bugs', icon: TbBug, key: 'bugs' }, { label: 'Bugs', icon: TbBug, key: 'bugs' },
{ label: 'App Logs', icon: TbServer, key: 'app-logs' }, { label: 'App Logs', icon: TbServer, key: 'app-logs', disabled: true },
{ label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, // { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
{ label: 'Database', icon: TbDatabase, key: 'database' }, { label: 'Database', icon: TbDatabase, key: 'database' },
{ label: 'Project', icon: TbSitemap, key: 'project' }, { label: 'Project', icon: TbSitemap, key: 'project' },
{ label: 'Settings', icon: TbSettings, key: 'settings' }, // { label: 'Settings', icon: TbSettings, key: 'settings' },
] ]
function DevPage() { function DevPage() {
@@ -204,7 +204,8 @@ function DevPage() {
variant={active === item.key ? 'filled' : 'subtle'} variant={active === item.key ? 'filled' : 'subtle'}
color={active === item.key ? 'blue' : 'gray'} color={active === item.key ? 'blue' : 'gray'}
size="lg" size="lg"
onClick={() => setActive(item.key)} disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
> >
<Icon size={18} /> <Icon size={18} />
</ActionIcon> </ActionIcon>
@@ -218,7 +219,8 @@ function DevPage() {
leftSection={<Icon size={16} />} leftSection={<Icon size={16} />}
rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined} rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined}
active={active === item.key} active={active === item.key}
onClick={() => setActive(item.key)} disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
style={{ borderRadius: 6 }} style={{ borderRadius: 6 }}
/> />
) )

View File

@@ -1,22 +1,24 @@
import { import {
ActionIcon,
Badge, Badge,
Center,
Container, Container,
Group, Group,
Stack, Loader,
Text,
Paper,
TextInput,
Select,
Avatar,
Box,
Divider,
Pagination, Pagination,
Center, SegmentedControl,
Tooltip, Select,
Stack,
Table,
Text,
Title,
} from '@mantine/core' } 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 { 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 { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
@@ -25,263 +27,144 @@ export const Route = createFileRoute('/logs')({
component: GlobalLogsPage, 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 }> = { const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const
CREATE: { color: 'blue', icon: TbCheck }, const LOG_TYPE_COLOR: Record<string, string> = {
UPDATE: { color: 'teal', icon: TbCheck }, LOGIN: 'green',
DELETE: { color: 'red', icon: TbX }, LOGOUT: 'gray',
LOGIN: { color: 'green', icon: TbClock }, CREATE: 'blue',
LOGOUT: { color: 'orange', icon: TbClock }, UPDATE: 'yellow',
} DELETE: 'red',
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
} }
function GlobalLogsPage() { function GlobalLogsPage() {
const [search, setSearch] = useState('') const [type, setType] = useState('all')
const [debouncedSearch, setDebouncedSearch] = useState('') const [operatorId, setOperatorId] = useState('all')
const [logType, setLogType] = useState<string | null>('all') const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const [operatorId, setOperatorId] = useState<string | null>('all')
const [page, setPage] = useState(1) 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 { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
const operatorOptions = useMemo(() => { 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 [ return [
{ value: 'all', label: 'All Operators' }, { value: 'all', label: 'Semua user' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name })) ...operatorsData.map((op: any) => ({ value: op.id, label: op.name })),
] ]
}, [operatorsData]) }, [operatorsData])
const { data: response, isLoading } = useSWR( const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'), const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
fetcher
const { data, isLoading, mutate } = useSWR(
API_URLS.getGlobalLogs(page, '', type, operatorId, dateFrom, dateTo),
fetcher,
{ refreshInterval: 10_000 },
) )
const filteredTimeline = useMemo(() => { const logs: any[] = data?.data ?? []
if (!response?.data) return [] const totalPages: number = data?.totalPages ?? 1
return groupLogsByDate(response.data)
}, [response?.data])
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
<Stack>
<Group justify="space-between">
<Title order={3}>Activity Logs</Title>
<ActionIcon variant="subtle" color="gray" onClick={() => mutate()}>
<TbRefresh size={16} />
</ActionIcon>
</Group>
{/* Header Controls */} <Group gap="sm" wrap="wrap">
<Group mb="xl" gap="md"> <Select
<TextInput placeholder="Filter user"
placeholder="Search operator or message..." value={operatorId}
leftSection={<TbSearch size={16} />} onChange={(v) => { setOperatorId(v ?? 'all'); setPage(1) }}
radius="md" data={operatorOptions}
w={250} w={180}
value={search} clearable
onChange={(e) => { />
setSearch(e.currentTarget.value) <DatePickerInput
setPage(1) type="range"
}} placeholder="Filter tanggal"
/> value={dateRange}
<Select onChange={(v) => { setDateRange(v); setPage(1) }}
placeholder="Log Type" locale="id"
data={[ valueFormat="DD MMM YYYY"
{ value: 'all', label: 'All Types' }, clearable
{ value: 'CREATE', label: 'Create' }, w={300}
{ value: 'UPDATE', label: 'Update' }, />
{ value: 'DELETE', label: 'Delete' }, <SegmentedControl
{ value: 'LOGIN', label: 'Login' }, value={type}
{ value: 'LOGOUT', label: 'Logout' }, onChange={(v) => { setType(v); setPage(1) }}
]} data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
radius="md" />
w={160} </Group>
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>
{/* Timeline Content */}
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{isLoading ? ( {isLoading ? (
<Center py="xl"> <Center py="xl"><Loader /></Center>
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : ( ) : (
<> <>
{filteredTimeline.map((group, groupIndex) => ( <Table.ScrollContainer minWidth={600}>
<Box key={group.date}> <Table striped highlightOnHover fz="xs" style={{ tableLayout: 'fixed', width: '100%' }}>
<Text <colgroup>
size="xs" <col style={{ width: 160 }} />
fw={700} <col style={{ width: 200 }} />
c="dimmed" <col style={{ width: 100 }} />
mt={groupIndex > 0 ? "xl" : 0} <col />
mb="md" </colgroup>
style={{ textTransform: 'uppercase' }} <Table.Thead>
> <Table.Tr>
{group.date} <Table.Th>Time</Table.Th>
</Text> <Table.Th>Operator</Table.Th>
<Table.Th>Type</Table.Th>
<Stack gap={0} pl={4}> <Table.Th>Message</Table.Th>
{group.logs.map((log, logIndex) => { </Table.Tr>
const isLastLog = logIndex === group.logs.length - 1; </Table.Thead>
<Table.Tbody>
return ( {logs.map((log: any) => (
<Group <Table.Tr key={log.id}>
key={log.id} <Table.Td style={{ whiteSpace: 'nowrap' }}>
wrap="nowrap" {new Date(log.createdAt).toLocaleString('id-ID')}
align="flex-start" </Table.Td>
gap="lg" <Table.Td>
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }} {log.user ? (
> <div>
{/* Left: Time */} <Text fw={500} truncate>{log.user.name}</Text>
<Text <Text c="dimmed" truncate>{log.user.email}</Text>
size="xs" </div>
c="dimmed" ) : <Text c="dimmed"></Text>}
w={70} </Table.Td>
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }} <Table.Td>
> <Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light">
{log.time} {log.type}
</Text> </Badge>
</Table.Td>
{/* Middle: Line & Avatar */} <Table.Td>
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}> <Text>{log.message}</Text>
{/* Vertical Line */} </Table.Td>
{!isLastLog && ( </Table.Tr>
<Box ))}
style={{ {logs.length === 0 && (
position: 'absolute', <Table.Tr>
top: 24, <Table.Td colSpan={4}>
bottom: -8, <Center py="xl"><Text c="dimmed">Belum ada log aktivitas</Text></Center>
left: '50%', </Table.Td>
transform: 'translateX(-50%)', </Table.Tr>
width: 1, )}
backgroundColor: 'rgba(128,128,128,0.2)' </Table.Tbody>
}} </Table>
/> </Table.ScrollContainer>
)} {totalPages > 1 && (
{/* Avatar */} <Center>
<Box style={{ position: 'relative', zIndex: 2 }}> <Pagination total={totalPages} value={page} onChange={setPage} size="sm" />
<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"
/>
</Center> </Center>
)} )}
</> </>
)} )}
</Paper> </Stack>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
) )