diff --git a/prisma/migrations/20260413071605_add/migration.sql b/prisma/migrations/20260413071605_add/migration.sql
new file mode 100644
index 0000000..1affce6
--- /dev/null
+++ b/prisma/migrations/20260413071605_add/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 35dbf84..ff103cb 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -125,13 +125,13 @@ model BugImage {
model BugLog {
id String @id @default(uuid())
bugId String
- userId String
+ userId String?
status BugStatus
description String
createdAt DateTime @default(now())
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("bug_log")
}
diff --git a/src/app.ts b/src/app.ts
index 4c90e03..4e236e6 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -260,6 +260,96 @@ export function createApp() {
})
})
+ .get('/api/bugs', async ({ query }) => {
+ const page = Number(query.page) || 1
+ const limit = Number(query.limit) || 20
+ const search = (query.search as string) || ''
+ const app = query.app as any
+ const status = query.status as any
+
+ const where: any = {}
+ if (search) {
+ where.OR = [
+ { description: { contains: search, mode: 'insensitive' } },
+ { device: { contains: search, mode: 'insensitive' } },
+ { os: { contains: search, mode: 'insensitive' } },
+ { affectedVersion: { contains: search, mode: 'insensitive' } },
+ ]
+ }
+ if (app && app !== 'all') {
+ where.app = app
+ }
+ if (status && status !== 'all') {
+ where.status = status
+ }
+
+ const [bugs, total] = await Promise.all([
+ prisma.bug.findMany({
+ where,
+ include: {
+ user: { select: { id: true, name: true, email: true, image: true } },
+ images: true,
+ logs: {
+ include: { user: { select: { id: true, name: true, image: true } } },
+ orderBy: { createdAt: 'desc' },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * limit,
+ take: limit,
+ }),
+ prisma.bug.count({ where }),
+ ])
+
+ return {
+ data: bugs,
+ totalPages: Math.ceil(total / limit),
+ totalItems: total,
+ }
+ })
+
+ .post('/api/bugs', 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 any
+ const bug = await prisma.bug.create({
+ data: {
+ app: body.app,
+ affectedVersion: body.affectedVersion,
+ device: body.device,
+ os: body.os,
+ status: body.status || 'OPEN',
+ source: body.source || 'USER',
+ description: body.description,
+ stackTrace: body.stackTrace,
+ userId: userId,
+ images: body.imageUrl ? {
+ create: {
+ imageUrl: body.imageUrl
+ }
+ } : undefined,
+ logs: {
+ create: {
+ userId: userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || '',
+ status: body.status || 'OPEN',
+ description: 'Bug reported initially.',
+ },
+ },
+ },
+ })
+
+ 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 1f59101..0cc88c4 100644
--- a/src/frontend/components/DashboardLayout.tsx
+++ b/src/frontend/components/DashboardLayout.tsx
@@ -29,7 +29,8 @@ import {
TbSun,
TbMoon,
TbUser,
- TbHistory
+ TbHistory,
+ TbBug
} from 'react-icons/tb'
interface DashboardLayoutProps {
@@ -52,6 +53,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: 'Users', icon: TbUser, to: '/users' },
]
diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts
index bb14c69..8c8a833 100644
--- a/src/frontend/config/api.ts
+++ b/src/frontend/config/api.ts
@@ -31,4 +31,7 @@ export const API_URLS = {
getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
getOperatorStats: () => `/api/operators/stats`,
+ getBugs: (page: number, search: string, app: string, status: string) =>
+ `/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
+ createBug: () => `/api/bugs`,
}
diff --git a/src/frontend/routes/bug-reports.tsx b/src/frontend/routes/bug-reports.tsx
new file mode 100644
index 0000000..eb6a934
--- /dev/null
+++ b/src/frontend/routes/bug-reports.tsx
@@ -0,0 +1,478 @@
+import { DashboardLayout } from '@/frontend/components/DashboardLayout'
+import { API_URLS } from '@/frontend/config/api'
+import {
+ Accordion,
+ Badge,
+ Box,
+ Button,
+ Code,
+ Container,
+ Group,
+ Image,
+ Loader,
+ Modal,
+ Pagination,
+ Paper,
+ Select,
+ SimpleGrid,
+ Stack,
+ Text,
+ ThemeIcon,
+ TextInput,
+ Textarea,
+ Title,
+ Timeline,
+} from '@mantine/core'
+import { useQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+import { useState } from 'react'
+import { useDisclosure } from '@mantine/hooks'
+import { notifications } from '@mantine/notifications'
+import {
+ TbAlertTriangle,
+ TbBug,
+ TbDeviceDesktop,
+ TbDeviceMobile,
+ TbFilter,
+ TbSearch,
+ TbHistory,
+ TbPhoto,
+ TbPlus,
+ TbCircleCheck,
+ TbCircleX,
+} from 'react-icons/tb'
+
+export const Route = createFileRoute('/bug-reports')({
+ component: ListErrorsPage,
+})
+
+function ListErrorsPage() {
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [app, setApp] = useState('all')
+ const [status, setStatus] = useState('all')
+
+ 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: 'desa_plus',
+ status: 'OPEN',
+ source: 'USER',
+ affectedVersion: '',
+ device: '',
+ os: '',
+ stackTrace: '',
+ imageUrl: '',
+ })
+
+ 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) {
+ notifications.show({
+ title: 'Success',
+ message: 'Bug 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 bug 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 (
+
+
+
+
+
+
+ Bug Reports
+
+
+ Centralized bug tracking and analysis for all applications.
+
+
+
+ }
+ onClick={open}
+ >
+ Report Bug
+
+ {/* }>
+ Generate Report
+ */}
+
+
+
+ Report New Bug}
+ radius="xl"
+ size="lg"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+
+
+
+
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ radius="md"
+ />
+
+
+ {isLoading ? (
+
+
+ Loading error reports...
+
+ ) : bugs.length === 0 ? (
+
+
+ No bugs found
+ Try adjusting your filters or search terms.
+
+ ) : (
+
+ {bugs.map((bug: any) => (
+
+
+
+
+
+
+
+
+
+ {bug.description}
+
+
+ {bug.status}
+
+
+
+
+ {new Date(bug.createdAt).toLocaleString()} • {bug.app.replace('_', ' ').toUpperCase()} • v{bug.affectedVersion}
+
+
+
+
+
+
+
+ {/* Device Info */}
+
+
+ DEVICE METADATA
+
+ {bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
+
+ ) : (
+
+ )}
+ {bug.device} ({bug.os})
+
+
+
+ SOURCE
+ {bug.source}
+
+
+
+ {/* Stack Trace */}
+ {bug.stackTrace && (
+
+ STACK TRACE
+
+ {bug.stackTrace}
+
+
+ )}
+
+ {/* Images */}
+ {bug.images && bug.images.length > 0 && (
+
+
+
+ ATTACHED IMAGES ({bug.images.length})
+
+
+ {bug.images.map((img: any) => (
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Logs / History */}
+ {bug.logs && bug.logs.length > 0 && (
+
+
+
+ ACTIVITY LOG
+
+
+ {bug.logs.map((log: any) => (
+
+ }
+ title={{log.status}}
+ >
+
+ {new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
+
+ {log.description}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {totalPages > 1 && (
+
+
+
+ )}
+
+
+
+
+ )
+}