feat: bug statistics + village detail dashboard enhancement

- Tambah GET /api/bugs/stats dengan summary cards & chart trend/bugs per app
- Tambah date range picker di village activity chart
- Tambah tabel Recent Activity (action + description) di village detail
- Update API graph-log-villages support dateFrom/dateTo custom range
This commit is contained in:
2026-05-25 15:00:33 +08:00
parent 2921f604a9
commit f368e1d31b
4 changed files with 377 additions and 24 deletions

View File

@@ -1,5 +1,7 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { API_URLS } from '@/frontend/config/api'
import { AreaChart, BarChart } from '@mantine/charts'
import {
Accordion,
Avatar,
@@ -37,8 +39,10 @@ import { useEffect, useState } from 'react'
import {
TbAlertTriangle,
TbBug,
TbChartBar,
TbCircleCheck,
TbCircleX,
TbClock,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
@@ -46,7 +50,9 @@ import {
TbPhoto,
TbPlus,
TbSearch,
TbTrendingUp,
} from 'react-icons/tb'
import useSWR from 'swr'
export const Route = createFileRoute('/bug-reports')({
component: ListErrorsPage,
@@ -77,6 +83,7 @@ function ListErrorsPage() {
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const [bugRange, setBugRange] = useState<7 | 30 | 90>(7)
const [debouncedSearch] = useDebouncedValue(search, 400)
@@ -103,6 +110,8 @@ function ListErrorsPage() {
queryFn: () => fetch(API_URLS.getBugs(page, searchQuery, app, status, source, dateFrom, dateTo)).then((r) => r.json()),
})
const { data: bugStats } = useSWR(API_URLS.getBugStats(bugRange), (url: string) => fetch(url).then((r) => r.json()))
const { data: appsList } = useQuery({
queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
@@ -247,6 +256,177 @@ function ListErrorsPage() {
</Button>
</Group>
{/* Bug Statistics Section */}
{bugStats && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
<SummaryCard
title="Total Bugs"
value={bugStats.totalBugs?.toLocaleString() ?? '0'}
icon={TbBug}
color="brand-blue"
/>
<SummaryCard
title="Open Bugs"
value={bugStats.openBugs?.toLocaleString() ?? '0'}
icon={TbAlertTriangle}
color="red"
isError={bugStats.openBugs > 0}
/>
<SummaryCard
title="Avg Resolution Time"
value={`${bugStats.avgResolutionHours ?? 0}h`}
icon={TbClock}
color="orange"
/>
<SummaryCard
title="Resolution Rate"
value={`${bugStats.resolutionRate ?? 0}%`}
icon={TbTrendingUp}
color="teal"
/>
</SimpleGrid>
)}
{bugStats && (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Paper withBorder radius="2xl" className="glass" p="md">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="brand-blue">
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Bugs per Application</Text>
</Stack>
</Group>
<BarChart
h={220}
data={(bugStats.byApp || []).map((item: { appId: string; count: number }) => ({
...item,
appId: item.appId.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
}))}
dataKey="appId"
series={[{ name: 'count', color: 'blue.6' }]}
withTooltip
tickLine="none"
gridAxis="x"
barProps={{
radius: [8, 8, 0, 0],
fill: 'url(#bugBarGradient)',
}}
xAxisProps={{
tick: { fontSize: 12, fill: '#909296' },
}}
tooltipProps={{
content: ({ active, payload }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
{payload[0]?.payload?.appId}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
</div>
</div>
)
},
}}
>
<defs>
<linearGradient id="bugBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0.8} />
</linearGradient>
</defs>
</BarChart>
</Paper>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="violet">
<TbTrendingUp size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Bug Trend</Text>
<Text size="xs" c="dimmed">Last {bugRange} days</Text>
</Stack>
</Group>
<Group gap={4}>
{([7, 30, 90] as const).map((r) => (
<Button
key={r}
size="compact-xs"
variant={bugRange === r ? 'filled' : 'subtle'}
color="violet"
radius="md"
onClick={() => setBugRange(r)}
>
{r === 7 ? '7D' : r === 30 ? '1M' : '3M'}
</Button>
))}
</Group>
</Group>
<AreaChart
h={220}
data={bugStats.trend || []}
dataKey="date"
series={[{ name: 'count', color: '#7C3AED' }]}
curveType="monotone"
withTooltip
tickLine="none"
gridAxis="x"
fillOpacity={0.3}
xAxisProps={{
interval: bugRange === 7 ? 0 : bugRange === 30 ? 4 : 9,
tick: { fontSize: 10, fill: '#909296' },
angle: bugRange === 7 ? 0 : -45,
textAnchor: 'end',
height: bugRange === 7 ? 30 : 60,
}}
tooltipProps={{
content: ({ active, payload }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
{payload[0]?.payload?.date}
</div>
<div style={{ fontSize: '11px', color: '#7C3AED' }}>
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
</div>
</div>
)
},
}}
styles={{
root: {
'.recharts-area-curve': {
strokeWidth: 2.5,
filter: 'drop-shadow(0 3px 6px rgba(124, 58, 237, 0.3))',
},
},
}}
/>
</Paper>
</SimpleGrid>
)}
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}