diff --git a/src/app.ts b/src/app.ts index f4f93e4..b1a38ef 100644 --- a/src/app.ts +++ b/src/app.ts @@ -89,7 +89,7 @@ export function createApp() { access_type: 'offline', prompt: 'consent', }) - set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}` + set.status = 302; set.headers['location'] = `https://accounts.google.com/o/oauth2/v2/auth?${params}` }) .get('/api/auth/callback/google', async ({ request, set }) => { @@ -98,7 +98,7 @@ export function createApp() { const origin = url.origin if (!code) { - set.status = 302; set.headers['location'] ='/login?error=google_failed' + set.status = 302; set.headers['location'] = '/login?error=google_failed' return } @@ -116,7 +116,7 @@ export function createApp() { }) if (!tokenRes.ok) { - set.status = 302; set.headers['location'] ='/login?error=google_failed' + set.status = 302; set.headers['location'] = '/login?error=google_failed' return } @@ -128,7 +128,7 @@ export function createApp() { }) if (!userInfoRes.ok) { - set.status = 302; set.headers['location'] ='/login?error=google_failed' + set.status = 302; set.headers['location'] = '/login?error=google_failed' return } @@ -154,18 +154,38 @@ export function createApp() { }) // ─── Monitoring API ──────────────────────────────── - .get('/api/dashboard/stats', () => ({ - totalApps: 3, - newErrors: 185, - activeUsers: '24.5k', - trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 } - })) + .get('/api/dashboard/stats', async () => { + const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } }) + const users = await prisma.user.count() + return { + totalApps: 1, + newErrors: newErrors, + activeUsers: users, + trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 } + } + }) - .get('/api/apps', () => [ - { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, - // { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, - // { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' }, - ]) + .get('/api/dashboard/recent-errors', async () => { + const bugs = await prisma.bug.findMany({ + take: 5, + orderBy: { createdAt: 'desc' } + }) + return bugs.map(b => ({ + id: b.id, + app: b.app, + message: b.description, + version: b.affectedVersion, + time: b.createdAt.toISOString(), + severity: b.status + })) + }) + + .get('/api/apps', async () => { + const desaPlusErrors = await prisma.bug.count({ where: { app: { in: ['desa-plus', 'desa_plus'] }, status: 'OPEN' } }) + return [ + { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: desaPlusErrors, version: '2.4.1' }, + ] + }) .get('/api/apps/:appId', ({ params: { appId } }) => { const apps = { @@ -265,7 +285,7 @@ export function createApp() { .get('/api/operators/stats', async () => { const [totalStaff, activeNow, rolesGroup] = await Promise.all([ - prisma.user.count(), + prisma.user.count({where: {active: true}}), prisma.session.count({ where: { expiresAt: { gte: new Date() } }, }), @@ -282,6 +302,99 @@ export function createApp() { } }) + .post('/api/operators', async ({ request, set }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + let userId: string | undefined + if (token) { + const session = await prisma.session.findUnique({ where: { token } }) + if (session && session.expiresAt > new Date()) userId = session.userId + } + + const body = (await request.json()) as { name: string; email: string; password: string; role: string } + + const existing = await prisma.user.findUnique({ where: { email: body.email } }) + if (existing) { + set.status = 400 + return { error: 'Email sudah terdaftar' } + } + + const hashedPassword = await Bun.password.hash(body.password) + const user = await prisma.user.create({ + data: { + name: body.name, + email: body.email, + password: hashedPassword, + role: body.role as any, + }, + }) + + if (userId) { + await createSystemLog(userId, 'CREATE', `Created new user: ${body.name} (${body.email})`) + } + + return { id: user.id, name: user.name, email: user.email, role: user.role } + }) + + .patch('/api/operators/:id', async ({ params: { id }, request, set }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + let userId: string | undefined + if (token) { + const session = await prisma.session.findUnique({ where: { token } }) + if (session && session.expiresAt > new Date()) userId = session.userId + } + + const body = (await request.json()) as { name?: string; email?: string; role?: string; active?: boolean } + + const user = await prisma.user.update({ + where: { id }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.email !== undefined && { email: body.email }), + ...(body.role !== undefined && { role: body.role as any }), + ...(body.active !== undefined && { active: body.active }), + }, + }) + + if (userId) { + await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`) + } + + return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active } + }) + + .delete('/api/operators/:id', async ({ params: { id }, request, set }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + let userId: string | undefined + if (token) { + const session = await prisma.session.findUnique({ where: { token } }) + if (session && session.expiresAt > new Date()) userId = session.userId + } + + const user = await prisma.user.findUnique({ where: { id } }) + if (!user) { + set.status = 404 + return { error: 'User not found' } + } + + // Prevent deleting self + if (userId === id) { + set.status = 400 + return { error: 'Cannot delete your own account' } + } + + await prisma.session.deleteMany({ where: { userId: id } }) + await prisma.user.update({ where: { id }, data: { active: false } }) + + if (userId) { + await createSystemLog(userId, 'DELETE', `Deactivated user: ${user.name} (${user.email})`) + } + + return { ok: true } + }) + .get('/api/logs/operators', async () => { return await prisma.user.findMany({ select: { id: true, name: true, image: true }, @@ -382,6 +495,73 @@ export function createApp() { return bug }) + .patch('/api/bugs/:id/feedback', async ({ params: { id }, request }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + let userId: string | undefined + + if (token) { + const session = await prisma.session.findUnique({ where: { token } }) + if (session && session.expiresAt > new Date()) { + userId = session.userId + } + } + + const body = (await request.json()) as { feedBack: string } + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const actingUserId = userId || defaultAdmin?.id || undefined + + const bug = await prisma.bug.update({ + where: { id }, + data: { + feedBack: body.feedBack, + }, + }) + + if (actingUserId) { + await createSystemLog(actingUserId, 'UPDATE', `Updated bug report feedback - ${id}`) + } + + return bug + }) + + .patch('/api/bugs/:id/status', async ({ params: { id }, request }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + let userId: string | undefined + + if (token) { + const session = await prisma.session.findUnique({ where: { token } }) + if (session && session.expiresAt > new Date()) { + userId = session.userId + } + } + + const body = (await request.json()) as { status: string; description?: string } + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const actingUserId = userId || defaultAdmin?.id || undefined + + const bug = await prisma.bug.update({ + where: { id }, + data: { + status: body.status as any, + logs: { + create: { + userId: actingUserId, + status: body.status as any, + description: body.description || `Status updated to ${body.status}`, + }, + }, + }, + }) + + if (actingUserId) { + await createSystemLog(actingUserId, 'UPDATE', `Updated bug report status to ${body.status}-${id}`) + } + + return bug + }) + // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ message: 'Hello, world!', diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 0cc88c4..5ec63f3 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -12,25 +12,26 @@ import { Select, Stack, Text, - ThemeIcon + ThemeIcon, + useComputedColorScheme, + useMantineColorScheme } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { + TbAlertTriangle, TbApps, TbArrowLeft, TbChevronRight, TbDashboard, TbDeviceMobile, - TbLogout, - TbSettings, - TbUserCircle, - TbSun, - TbMoon, - TbUser, TbHistory, - TbBug + TbLogout, + TbMoon, + TbSettings, + TbSun, + TbUser, + TbUserCircle } from 'react-icons/tb' interface DashboardLayoutProps { @@ -53,7 +54,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Log Activity', icon: TbHistory, to: '/logs' }, - { label: 'Bug Reports', icon: TbBug, to: '/bug-reports' }, + { label: 'Error Reports', icon: TbAlertTriangle, to: '/bug-reports' }, { label: 'Users', icon: TbUser, to: '/users' }, ] diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 9472c17..6c10800 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -31,8 +31,13 @@ export const API_URLS = { getOperators: (page: number, search: string) => `/api/operators?page=${page}&search=${encodeURIComponent(search)}`, getOperatorStats: () => `/api/operators/stats`, + 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}`, createBug: () => `/api/bugs`, + updateBugStatus: (id: string) => `/api/bugs/${id}/status`, + updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`, createLog: () => `/api/logs`, } diff --git a/src/frontend/routes/apps.$appId.errors.tsx b/src/frontend/routes/apps.$appId.errors.tsx index 071ef1b..4fabc65 100644 --- a/src/frontend/routes/apps.$appId.errors.tsx +++ b/src/frontend/routes/apps.$appId.errors.tsx @@ -1,78 +1,238 @@ import { - Badge, - Container, - Group, - Stack, - Text, - Title, - Paper, Accordion, - ThemeIcon, - TextInput, - Select, - Code, + Avatar, + Badge, Box, Button, + Code, + Collapse, + Group, + Image, + Loader, + Modal, + Pagination, + Paper, + Select, SimpleGrid, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, + Timeline, + Title } from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' +import { useQuery } from '@tanstack/react-query' import { createFileRoute, useParams } from '@tanstack/react-router' -import { - TbAlertTriangle, - TbBug, - TbDeviceDesktop, - TbDeviceMobile, - TbSearch, - TbFilter, +import { useState } from 'react' +import { + TbAlertTriangle, + TbBug, TbCircleCheck, - TbUserCheck + TbCircleX, + TbDeviceDesktop, + TbDeviceMobile, + TbFilter, + TbHistory, + TbPhoto, + TbPlus, + TbSearch } from 'react-icons/tb' +import { API_URLS } from '../config/api' export const Route = createFileRoute('/apps/$appId/errors')({ component: AppErrorsPage, }) -const mockErrors = [ - { - id: 1, - title: 'NullPointerException: village_id is null', - message: 'Occurred during background sync with central server.', - version: '2.4.1', - device: 'PC Admin (Windows 10)', - time: '2 mins ago', - severity: 'critical', - users: 24, - frequency: 145, - stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)' - }, - { - id: 2, - title: 'SocketTimeoutException: Connection reset by peer', - message: 'Failed to upload document: surat_kematian_01.pdf', - version: '2.4.0', - device: 'Android Tablet (Samsung Tab A8)', - time: '15 mins ago', - severity: 'high', - users: 5, - frequency: 12, - stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)' - }, - { - id: 3, - title: 'SQLiteException: no such column: village_id', - message: 'Failed to query local village profile database.', - version: '2.4.1', - device: 'PC Admin (Windows 7)', - time: '1 hour ago', - severity: 'medium', - users: 2, - frequency: 4, - stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)' - }, -] function AppErrorsPage() { const { appId } = useParams({ from: '/apps/$appId/errors' }) + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + const [app, setApp] = useState(appId) + const [status, setStatus] = useState('all') + + + const [showLogs, setShowLogs] = useState>({}) + + const toggleLogs = (bugId: string) => { + setShowLogs((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()), + }) + + // Create Bug Modal Logic + const [opened, { open, close }] = useDisclosure(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [createForm, setCreateForm] = useState({ + description: '', + app: appId, + status: 'OPEN', + source: 'USER', + affectedVersion: '', + device: '', + os: '', + stackTrace: '', + imageUrl: '', + }) + + // Update Status Modal Logic + const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false) + const [isUpdating, setIsUpdating] = useState(false) + const [selectedBugId, setSelectedBugId] = useState(null) + const [updateForm, setUpdateForm] = useState({ + status: '', + description: '', + }) + + // Feedback Modal Logic + const [feedbackModalOpened, { open: openFeedbackModal, close: closeFeedbackModal }] = useDisclosure(false) + const [isUpdatingFeedback, setIsUpdatingFeedback] = useState(false) + const [feedbackForm, setFeedbackForm] = useState({ + feedBack: '', + }) + + const handleUpdateFeedback = async () => { + if (!selectedBugId || !feedbackForm.feedBack) return + + setIsUpdatingFeedback(true) + try { + const res = await fetch(API_URLS.updateBugFeedback(selectedBugId), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(feedbackForm), + }) + + if (res.ok) { + notifications.show({ + title: 'Success', + message: 'Feedback has been updated.', + color: 'teal', + icon: , + }) + refetch() + closeFeedbackModal() + setFeedbackForm({ feedBack: '' }) + } else { + throw new Error('Failed to update feedback') + } + } catch (e) { + notifications.show({ + title: 'Error', + message: 'Something went wrong.', + color: 'red', + icon: , + }) + } finally { + setIsUpdatingFeedback(false) + } + } + + const handleUpdateStatus = async () => { + if (!selectedBugId || !updateForm.status) return + + setIsUpdating(true) + try { + const res = await fetch(API_URLS.updateBugStatus(selectedBugId), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateForm), + }) + + if (res.ok) { + notifications.show({ + title: 'Success', + message: 'Status has been updated.', + color: 'teal', + icon: , + }) + refetch() + closeUpdateModal() + setUpdateForm({ status: '', description: '' }) + } else { + throw new Error('Failed to update status') + } + } catch (e) { + notifications.show({ + title: 'Error', + message: 'Something went wrong.', + color: 'red', + icon: , + }) + } finally { + setIsUpdating(false) + } + } + + const handleCreateBug = async () => { + if (!createForm.description || !createForm.affectedVersion || !createForm.device || !createForm.os) { + notifications.show({ + title: 'Validation Error', + message: 'Please fill in all required fields.', + color: 'red', + }) + return + } + + setIsSubmitting(true) + try { + const res = await fetch(API_URLS.createBug(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createForm), + }) + + if (res.ok) { + await fetch(API_URLS.createLog(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'CREATE', message: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` }) + }).catch(console.error) + + notifications.show({ + title: 'Success', + message: 'Error report has been created.', + color: 'teal', + icon: , + }) + refetch() + close() + setCreateForm({ + description: '', + app: 'desa_plus', + status: 'OPEN', + source: 'USER', + affectedVersion: '', + device: '', + os: '', + stackTrace: '', + imageUrl: '', + }) + } else { + throw new Error('Failed to create error report') + } + } catch (e) { + notifications.show({ + title: 'Error', + message: 'Something went wrong.', + color: 'red', + icon: , + }) + } finally { + setIsSubmitting(false) + } + } + + const bugs = data?.data || [] + const totalPages = data?.totalPages || 1 + return ( @@ -80,93 +240,434 @@ function AppErrorsPage() { Error Reporting Center Advanced analysis of health issues and crashes for {appId}. - - - + Update Bug Status} + radius="xl" + overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} + > + +