diff --git a/.gitignore b/.gitignore index a95f9ee..d755504 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ src/frontend/routeTree.gen.ts # IntelliJ based IDEs .idea +# Claude Code session data +.claude/ + # Finder (MacOS) folder config .DS_Store diff --git a/package.json b/package.json index 87439d6..ea98061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bun-react-template", - "version": "0.1.15", + "version": "0.1.16", "private": true, "type": "module", "scripts": { diff --git a/src/app.ts b/src/app.ts index 65b47ce..5d6b462 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,7 +8,7 @@ import { prisma } from './lib/db' import { env } from './lib/env' import { createSystemLog } from './lib/logger' import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' -import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence' +import { addConnection, broadcastNotification, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence' import { parseSchema } from './lib/schema-parser' const isProduction = process.env.NODE_ENV === 'production' @@ -805,6 +805,9 @@ export function createApp() { const search = query.search || '' const app = query.app as any const status = query.status as any + const source = query.source as any + const dateFrom = query.dateFrom + const dateTo = query.dateTo const where: any = {} if (search) { @@ -821,6 +824,18 @@ export function createApp() { if (status && status !== 'all') { where.status = status } + if (source && source !== 'all') { + where.source = source + } + if (dateFrom || dateTo) { + where.createdAt = {} + if (dateFrom) where.createdAt.gte = new Date(dateFrom) + if (dateTo) { + const end = new Date(dateTo) + end.setHours(23, 59, 59, 999) + where.createdAt.lte = end + } + } const [bugs, total] = await Promise.all([ prisma.bug.findMany({ @@ -852,10 +867,13 @@ export function createApp() { search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })), app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })), status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })), + source: t.Optional(t.String({ description: 'Filter sumber: QC | SYSTEM | USER | all' })), + dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (YYYY-MM-DD)' })), + dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (YYYY-MM-DD)' })), }), detail: { summary: 'List Bug Reports', - description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.', + description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi, status, source, dan tanggal.', tags: ['Bugs'], }, }) @@ -903,6 +921,18 @@ export function createApp() { }, }) + broadcastNotification({ + type: 'new_bug', + bug: { + id: bug.id, + description: bug.description, + appId: bug.appId, + source: bug.source, + affectedVersion: bug.affectedVersion, + createdAt: bug.createdAt, + }, + }) + return bug }, { body: t.Object({ @@ -1070,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 { @@ -1223,9 +1335,11 @@ export function createApp() { include: { user: { select: { id: true, role: true } } }, }) if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return } - const isAdmin = session.user.role === 'DEVELOPER' + const role = session.user.role + const isAdmin = role === 'DEVELOPER' + const canReceiveNotifs = role === 'DEVELOPER' || role === 'ADMIN' ;(ws.data as unknown as { userId: string }).userId = session.user.id - addConnection(ws as any, session.user.id, isAdmin) + addConnection(ws as any, session.user.id, isAdmin, canReceiveNotifs) }, close(ws) { removeConnection(ws as any) }, message() {}, @@ -1656,6 +1770,19 @@ export function createApp() { return { keys: json.data ?? [] } }) + .get('/api/admin/api-keys/:id', async ({ request, set, params }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } }) + if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } } + const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, { + headers: { 'x-api-key': app.apiKey ?? '' }, + }) + const json = await res.json() + set.status = res.status + return json + }) + .post('/api/admin/api-keys', async ({ request, set }) => { const auth = await requireDeveloper(request, set) if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 9470c48..c424efa 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,5 +1,6 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' import { useLogout, useSession } from '@/frontend/hooks/useAuth' +import { usePresence } from '@/frontend/hooks/usePresence' import React from 'react' import { ActionIcon, @@ -24,12 +25,14 @@ import { useMantineColorScheme } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' import { useQuery } from '@tanstack/react-query' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { TbAlertTriangle, TbApps, TbArrowLeft, + TbBug, TbChevronRight, TbClock, TbDashboard, @@ -64,6 +67,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const user = sessionData?.user const logout = useLogout() + // ─── Real-time bug notifications ───────────────────── + usePresence((bug) => { + const appLabel = bug.appId ? bug.appId.toUpperCase() : 'Unknown App' + notifications.show({ + id: `new-bug-${bug.id}`, + title: `New bug report — ${appLabel}`, + message: bug.description.length > 80 ? `${bug.description.slice(0, 80)}…` : bug.description, + color: 'red', + icon: React.createElement(TbBug, { size: 18 }), + autoClose: 8000, + withBorder: true, + }) + }) + // Redirect USER role to profile (pending approval) React.useEffect(() => { if (!sessionLoading && user?.role === 'USER') { diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 2264dea..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) @@ -51,9 +57,15 @@ export const API_URLS = { createOperator: () => `/api/operators`, editOperator: (id: string) => `/api/operators/${id}`, deleteOperator: (id: string) => `/api/operators/${id}`, - getBugs: (page: number, search: string, app: string, status: string) => - `/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`, + getBugs: (page: number, search: string, app: string, status: string, source?: string, dateFrom?: string, dateTo?: string) => { + const params = new URLSearchParams({ page: String(page), search: encodeURIComponent(search), app, status }) + if (source && source !== 'all') params.set('source', source) + if (dateFrom) params.set('dateFrom', dateFrom) + if (dateTo) params.set('dateTo', dateTo) + 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/hooks/usePresence.ts b/src/frontend/hooks/usePresence.ts index 4ab296e..d5d97fe 100644 --- a/src/frontend/hooks/usePresence.ts +++ b/src/frontend/hooks/usePresence.ts @@ -1,11 +1,22 @@ import { useEffect, useRef, useState } from 'react' import { useSession } from './useAuth' -export function usePresence() { +export interface NewBugPayload { + id: string + description: string + appId: string | null + source: string + affectedVersion: string + createdAt: string +} + +export function usePresence(onNewBug?: (bug: NewBugPayload) => void) { const { data } = useSession() const [onlineUserIds, setOnlineUserIds] = useState([]) const wsRef = useRef(null) const reconnectTimer = useRef | undefined>(undefined) + const onNewBugRef = useRef(onNewBug) + onNewBugRef.current = onNewBug useEffect(() => { if (!data?.user) return @@ -18,6 +29,7 @@ export function usePresence() { ws.onmessage = (e) => { const msg = JSON.parse(e.data) if (msg.type === 'presence') setOnlineUserIds(msg.online) + if (msg.type === 'new_bug') onNewBugRef.current?.(msg.bug) } ws.onclose = () => { wsRef.current = null 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 7c0c10a..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, @@ -27,17 +29,20 @@ import { Title, Tooltip, } from '@mantine/core' -import { useDisclosure } from '@mantine/hooks' +import { useDebouncedValue, useDisclosure } from '@mantine/hooks' +import { DatePickerInput, type DatesRangeValue } from '@mantine/dates' import { notifications } from '@mantine/notifications' import { useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import dayjs from 'dayjs' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { TbAlertTriangle, TbBug, + TbChartBar, TbCircleCheck, TbCircleX, + TbClock, TbDeviceDesktop, TbDeviceMobile, TbFilter, @@ -45,7 +50,9 @@ import { TbPhoto, TbPlus, TbSearch, + TbTrendingUp, } from 'react-icons/tb' +import useSWR from 'swr' export const Route = createFileRoute('/bug-reports')({ component: ListErrorsPage, @@ -71,20 +78,40 @@ const STATUS_LABEL: Record = { function ListErrorsPage() { const [page, setPage] = useState(1) const [search, setSearch] = useState('') + const [searchQuery, setSearchQuery] = useState('') const [app, setApp] = useState('all') 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) + + useEffect(() => { + if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) { + setSearchQuery(debouncedSearch) + setPage(1) + } + }, [debouncedSearch]) + + useEffect(() => { setPage(1) }, [app, status, source, dateRange]) const [showLogs, setShowLogs] = useState>({}) const [showStackTrace, setShowStackTrace] = useState>({}) + 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 toggleLogs = (bugId: string) => setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] })) const toggleStackTrace = (bugId: string) => setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] })) const { data, isLoading, refetch } = useQuery({ - queryKey: ['bugs', { page, search, app, status }], - queryFn: () => fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()), + queryKey: ['bugs', { page, searchQuery, app, status, source, dateFrom, dateTo }], + 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()), @@ -229,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 */} - + setStatus(val || 'all')} radius="md" /> +