amalia/25-mei-26 #26

Merged
amaliadwiy merged 6 commits from amalia/25-mei-26 into main 2026-05-25 17:33:50 +08:00
10 changed files with 551 additions and 313 deletions
Showing only changes of commit f368e1d31b - Show all commits

View File

@@ -1100,6 +1100,88 @@ export function createApp() {
},
})
// ─── Bug Statistics API ────────────────────────────
.get('/api/bugs/stats', async ({ query }) => {
const range = [7, 30, 90].includes(Number(query.range)) ? Number(query.range) : 7
const now = new Date()
const rangeStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000)
const [totalBugs, openBugs, statusGroups, appGroups, sourceGroups, resolvedBugs, trendData] = await Promise.all([
prisma.bug.count(),
prisma.bug.count({ where: { status: 'OPEN' } }),
prisma.bug.groupBy({ by: ['status'], _count: { id: true } }),
prisma.bug.groupBy({ by: ['appId'], _count: { id: true } }),
prisma.bug.groupBy({ by: ['source'], _count: { id: true } }),
prisma.bug.findMany({
where: { status: { in: ['RESOLVED', 'CLOSED'] } },
select: { createdAt: true, updatedAt: true },
}),
prisma.bug.findMany({
where: { createdAt: { gte: rangeStart } },
select: { createdAt: true },
orderBy: { createdAt: 'asc' },
}),
])
const byStatus = Object.fromEntries(statusGroups.map((g) => [g.status, g._count.id]))
const byApp = appGroups.map((g) => ({ appId: g.appId, count: g._count.id }))
const bySource = Object.fromEntries(sourceGroups.map((g) => [g.source, g._count.id]))
const totalResolutionMs = resolvedBugs.reduce((sum, b) => sum + (b.updatedAt.getTime() - b.createdAt.getTime()), 0)
const avgResolutionHours = resolvedBugs.length > 0
? Math.round(totalResolutionMs / resolvedBugs.length / (1000 * 60 * 60) * 10) / 10
: 0
const resolvedCount = (byStatus['RESOLVED'] || 0) + (byStatus['CLOSED'] || 0)
const resolutionRate = totalBugs > 0 ? Math.round((resolvedCount / totalBugs) * 100) : 0
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const trendMap: Record<string, number> = {}
const keyToLabel: Record<string, string> = {}
for (let i = 0; i < range; i++) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
const label = `${d.getDate()} ${months[d.getMonth()]}`
keyToLabel[key] = label
trendMap[key] = 0
}
for (const b of trendData) {
const key = b.createdAt.toISOString().slice(0, 10)
if (key in trendMap) trendMap[key]++
}
const trend: { date: string; count: number }[] = []
for (let i = 0; i < range; i++) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
trend.push({ date: keyToLabel[key] ?? key, count: trendMap[key] ?? 0 })
}
trend.reverse()
return {
totalBugs,
openBugs,
byStatus,
byApp,
bySource,
avgResolutionHours,
resolutionRate,
trend,
range,
}
}, {
query: t.Object({
range: t.Optional(t.String({ description: 'Rentang hari: 7, 30, atau 90 (default: 30)' })),
}),
detail: {
summary: 'Bug Statistics',
description: 'Statistik bug: total, distribusi status, per app, per source, avg resolution time, dan trend.',
tags: ['Bugs'],
},
})
// ─── System Status API ─────────────────────────────
.get('/api/system/status', async () => {
try {

View File

@@ -7,8 +7,14 @@ export const API_URLS = {
`${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`,
gridVillages: (id: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
graphLogVillages: (id: string, time: string, dateFrom?: string, dateTo?: string) => {
const params = new URLSearchParams({ id, time })
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
return `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?${params}`
},
getRecentVillageLogs: (id: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/recent-village-logs?id=${id}`,
getUsers: (page: number, search: string, isActive?: string, idUserRole?: string, idVillage?: string, orderBy?: string, orderDir?: string) => {
const params = new URLSearchParams({ page: String(page), search })
if (isActive !== undefined) params.set('isActive', isActive)
@@ -59,6 +65,7 @@ export const API_URLS = {
return `/api/bugs?${params}`
},
createBug: () => `/api/bugs`,
getBugStats: (range: 7 | 30 | 90 = 30) => `/api/bugs/stats?range=${range}`,
uploadImage: () => `/api/upload/image`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,

View File

@@ -12,6 +12,7 @@ import {
SimpleGrid,
Stack,
Switch,
Table,
Text,
Textarea,
TextInput,
@@ -19,8 +20,10 @@ import {
Title,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import dayjs from 'dayjs'
import { useState } from 'react'
import {
TbArrowLeft,
@@ -28,6 +31,7 @@ import {
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbClock,
TbEdit,
TbHome2,
TbLayoutKanban,
@@ -65,11 +69,17 @@ type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const { data: response, isLoading } = useSWR(
API_URLS.graphLogVillages(villageId, period),
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 hasCustomRange = !!(dateFrom && dateTo)
const apiUrl = hasCustomRange
? API_URLS.graphLogVillages(villageId, period, dateFrom, dateTo)
: API_URLS.graphLogVillages(villageId, period)
const { data: response, isLoading } = useSWR(apiUrl, fetcher)
const labels: Record<ChartPeriod, string> = {
daily: 'Daily (last 14 days)',
@@ -79,7 +89,6 @@ function ActivityChart({ villageId }: { villageId: string }) {
const rawData: any[] = Array.isArray(response?.data) ? response.data : []
// Normalize: map any field names from external API → { label, activity }
const data = rawData.map((item) => {
const label = item.label
const activity = item.aktivitas
@@ -95,21 +104,37 @@ function ActivityChart({ villageId }: { villageId: string }) {
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Village Activity Log</Text>
<Text size="xs" c="dimmed">{labels[period]}</Text>
<Text size="xs" c="dimmed">
{hasCustomRange ? `${dateFrom}${dateTo}` : labels[period]}
</Text>
</Stack>
</Group>
<SegmentedControl
value={period}
onChange={(v) => setPeriod(v as ChartPeriod)}
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
<Group gap="sm" wrap="wrap">
<DatePickerInput
type="range"
placeholder="Pick date range"
size="xs"
radius="md"
value={dateRange}
onChange={setDateRange}
clearable
w={200}
/>
{!hasCustomRange && (
<SegmentedControl
value={period}
onChange={(v) => setPeriod(v as ChartPeriod)}
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
)}
</Group>
</Group>
{isLoading ? (
@@ -168,6 +193,64 @@ function ActivityChart({ villageId }: { villageId: string }) {
)
}
// ── Recent Activity Logs ──────────────────────────────────────────────────────
function RecentVillageLogs({ villageId }: { villageId: string }) {
const { data: response, isLoading } = useSWR(API_URLS.getRecentVillageLogs(villageId), fetcher)
const logs: any[] = Array.isArray(response?.data) ? response.data : []
return (
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbClock size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Recent Activity</Text>
<Text size="xs" c="dimmed">Latest user actions in this village</Text>
</Stack>
</Group>
{isLoading ? (
<Stack h={120} align="center" justify="center">
<Loader type="dots" />
</Stack>
) : logs.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">No recent activity.</Text>
) : (
<Table verticalSpacing="xs" className="data-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Action</Table.Th>
<Table.Th>Description</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log: any, i: number) => (
<Table.Tr key={i}>
<Table.Td>
<Text size="xs">{dayjs(log.timestamp).format('D MMM YYYY, HH:mm')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{log.userName || 'Unknown'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{log.action || '-'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" lineClamp={1}>{log.desc || '-'}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function VillageDetailPage() {
@@ -474,21 +557,22 @@ function VillageDetailPage() {
))}
</SimpleGrid>
{/* ── Chart + Info Panels ── */}
{/* ── Activity Chart ── */}
<ActivityChart villageId={villageId} />
{/* ── Recent Logs + System Info ── */}
<Box
style={{
display: 'grid',
gridTemplateColumns: '3fr 1fr',
gridTemplateColumns: '2fr 1fr',
gap: '1rem',
alignItems: 'start',
}}
>
{/* Left (3/4): Activity Chart */}
<Box style={{ minWidth: 0 }}>
<ActivityChart villageId={villageId} />
<RecentVillageLogs villageId={villageId} />
</Box>
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">

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}