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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user