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

@@ -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'

View File

@@ -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)}`,

View File

@@ -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 }}
/>
)

View File

@@ -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>
)