diff --git a/src/app.ts b/src/app.ts index cd4ac56..5d6b462 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 = {} + const keyToLabel: Record = {} + + 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 { diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index a9e6d4c..76878f0 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -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`, diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index c7cdb20..1fb5aa5 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -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('daily') + const [dateRange, setDateRange] = useState([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 = { 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 }) { Village Activity Log - {labels[period]} + + {hasCustomRange ? `${dateFrom} — ${dateTo}` : labels[period]} + - setPeriod(v as ChartPeriod)} - size="xs" - radius="md" - data={[ - { value: 'daily', label: 'Daily' }, - { value: 'monthly', label: 'Monthly' }, - { value: 'yearly', label: 'Yearly' }, - ]} - /> + + + {!hasCustomRange && ( + setPeriod(v as ChartPeriod)} + size="xs" + radius="md" + data={[ + { value: 'daily', label: 'Daily' }, + { value: 'monthly', label: 'Monthly' }, + { value: 'yearly', label: 'Yearly' }, + ]} + /> + )} + {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 ( + + + + + + + Recent Activity + Latest user actions in this village + + + + {isLoading ? ( + + + + ) : logs.length === 0 ? ( + No recent activity. + ) : ( + + + + Time + User + Action + Description + + + + {logs.map((log: any, i: number) => ( + + + {dayjs(log.timestamp).format('D MMM YYYY, HH:mm')} + + + {log.userName || 'Unknown'} + + + {log.action || '-'} + + + {log.desc || '-'} + + + ))} + +
+ )} +
+ ) +} + // ── Main Page ───────────────────────────────────────────────────────────────── function VillageDetailPage() { @@ -474,21 +557,22 @@ function VillageDetailPage() { ))} - {/* ── Chart + Info Panels ── */} + {/* ── Activity Chart ── */} + + + {/* ── Recent Logs + System Info ── */} - {/* Left (3/4): Activity Chart */} - + - {/* Right (1/4): Informasi Sistem */} diff --git a/src/frontend/routes/bug-reports.tsx b/src/frontend/routes/bug-reports.tsx index 50c54d8..bc67d67 100644 --- a/src/frontend/routes/bug-reports.tsx +++ b/src/frontend/routes/bug-reports.tsx @@ -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([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() { + {/* Bug Statistics Section */} + {bugStats && ( + + + 0} + /> + + + + )} + + {bugStats && ( + + + + + + + + Bugs per Application + + + ({ + ...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 ( +
+
+ {payload[0]?.payload?.appId} +
+
+ Bugs: {payload[0]?.value} +
+
+ ) + }, + }} + > + + + + + + +
+
+ + + + + + + + Bug Trend + Last {bugRange} days + + + + {([7, 30, 90] as const).map((r) => ( + + ))} + + + { + if (!active || !payload?.length) return null + return ( +
+
+ {payload[0]?.payload?.date} +
+
+ Bugs: {payload[0]?.value} +
+
+ ) + }, + }} + styles={{ + root: { + '.recharts-area-curve': { + strokeWidth: 2.5, + filter: 'drop-shadow(0 3px 6px rgba(124, 58, 237, 0.3))', + }, + }, + }} + /> +
+
+ )} + {/* Image Preview Modal */}