4 Commits

Author SHA1 Message Date
24fcc1ee76 upd: user staff
Deskripsi:
- connected to database pada halaman user
- tambah user
- delete user
- update user

No
Issues
2026-04-14 16:41:03 +08:00
f38081b1eb upd: menu dashboard
Deskripsi:
- connected to database

No Issues
2026-04-14 16:24:17 +08:00
a0cafbf2e2 upd: connected ke db
Deskripi:
- list error report general dan per apps
- update status
- update feedback

No Issues
2026-04-14 12:05:34 +08:00
14adaa8526 Merge pull request 'amalia/13-apr-26' (#7) from amalia/13-apr-26 into main
Reviewed-on: #7
2026-04-13 17:19:36 +08:00
8 changed files with 1380 additions and 213 deletions

View File

@@ -89,7 +89,7 @@ export function createApp() {
access_type: 'offline', access_type: 'offline',
prompt: 'consent', 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 }) => { .get('/api/auth/callback/google', async ({ request, set }) => {
@@ -98,7 +98,7 @@ export function createApp() {
const origin = url.origin const origin = url.origin
if (!code) { if (!code) {
set.status = 302; set.headers['location'] ='/login?error=google_failed' set.status = 302; set.headers['location'] = '/login?error=google_failed'
return return
} }
@@ -116,7 +116,7 @@ export function createApp() {
}) })
if (!tokenRes.ok) { if (!tokenRes.ok) {
set.status = 302; set.headers['location'] ='/login?error=google_failed' set.status = 302; set.headers['location'] = '/login?error=google_failed'
return return
} }
@@ -128,7 +128,7 @@ export function createApp() {
}) })
if (!userInfoRes.ok) { if (!userInfoRes.ok) {
set.status = 302; set.headers['location'] ='/login?error=google_failed' set.status = 302; set.headers['location'] = '/login?error=google_failed'
return return
} }
@@ -154,18 +154,38 @@ export function createApp() {
}) })
// ─── Monitoring API ──────────────────────────────── // ─── Monitoring API ────────────────────────────────
.get('/api/dashboard/stats', () => ({ .get('/api/dashboard/stats', async () => {
totalApps: 3, const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
newErrors: 185, const users = await prisma.user.count()
activeUsers: '24.5k', return {
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 } totalApps: 1,
})) newErrors: newErrors,
activeUsers: users,
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
}
})
.get('/api/apps', () => [ .get('/api/dashboard/recent-errors', async () => {
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, const bugs = await prisma.bug.findMany({
// { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, take: 5,
// { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.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 } }) => { .get('/api/apps/:appId', ({ params: { appId } }) => {
const apps = { const apps = {
@@ -265,7 +285,7 @@ export function createApp() {
.get('/api/operators/stats', async () => { .get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([ const [totalStaff, activeNow, rolesGroup] = await Promise.all([
prisma.user.count(), prisma.user.count({where: {active: true}}),
prisma.session.count({ prisma.session.count({
where: { expiresAt: { gte: new Date() } }, 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 () => { .get('/api/logs/operators', async () => {
return await prisma.user.findMany({ return await prisma.user.findMany({
select: { id: true, name: true, image: true }, select: { id: true, name: true, image: true },
@@ -382,6 +495,73 @@ export function createApp() {
return bug 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 ─────────────────────────────────── // ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({ .get('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',

View File

@@ -12,25 +12,26 @@ import {
Select, Select,
Stack, Stack,
Text, Text,
ThemeIcon ThemeIcon,
useComputedColorScheme,
useMantineColorScheme
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import { import {
TbAlertTriangle,
TbApps, TbApps,
TbArrowLeft, TbArrowLeft,
TbChevronRight, TbChevronRight,
TbDashboard, TbDashboard,
TbDeviceMobile, TbDeviceMobile,
TbLogout,
TbSettings,
TbUserCircle,
TbSun,
TbMoon,
TbUser,
TbHistory, TbHistory,
TbBug TbLogout,
TbMoon,
TbSettings,
TbSun,
TbUser,
TbUserCircle
} from 'react-icons/tb' } from 'react-icons/tb'
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -53,7 +54,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Log Activity', icon: TbHistory, to: '/logs' }, { 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' }, { label: 'Users', icon: TbUser, to: '/users' },
] ]

View File

@@ -31,8 +31,13 @@ export const API_URLS = {
getOperators: (page: number, search: string) => getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`, `/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
getOperatorStats: () => `/api/operators/stats`, 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) => getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`, `/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`, createBug: () => `/api/bugs`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
createLog: () => `/api/logs`, createLog: () => `/api/logs`,
} }

View File

@@ -1,78 +1,238 @@
import { import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Accordion, Accordion,
ThemeIcon, Avatar,
TextInput, Badge,
Select,
Code,
Box, Box,
Button, Button,
Code,
Collapse,
Group,
Image,
Loader,
Modal,
Pagination,
Paper,
Select,
SimpleGrid, SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Timeline,
Title
} from '@mantine/core' } 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 { createFileRoute, useParams } from '@tanstack/react-router'
import { import { useState } from 'react'
TbAlertTriangle, import {
TbBug, TbAlertTriangle,
TbDeviceDesktop, TbBug,
TbDeviceMobile,
TbSearch,
TbFilter,
TbCircleCheck, TbCircleCheck,
TbUserCheck TbCircleX,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
TbHistory,
TbPhoto,
TbPlus,
TbSearch
} from 'react-icons/tb' } from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/errors')({ export const Route = createFileRoute('/apps/$appId/errors')({
component: AppErrorsPage, 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() { function AppErrorsPage() {
const { appId } = useParams({ from: '/apps/$appId/errors' }) 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<Record<string, boolean>>({})
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<string | null>(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: <TbCircleCheck size={18} />,
})
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: <TbCircleX size={18} />,
})
} 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: <TbCircleCheck size={18} />,
})
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: <TbCircleX size={18} />,
})
} 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: <TbCircleCheck size={18} />,
})
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: <TbCircleX size={18} />,
})
} finally {
setIsSubmitting(false)
}
}
const bugs = data?.data || []
const totalPages = data?.totalPages || 1
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@@ -80,93 +240,434 @@ function AppErrorsPage() {
<Title order={3}>Error Reporting Center</Title> <Title order={3}>Error Reporting Center</Title>
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text> <Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
</Stack> </Stack>
<Button variant="light" color="red" leftSection={<TbBug size={16} />}> <Button
Export Logs variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
onClick={open}
>
Report Error
</Button> </Button>
</Group> </Group>
<Paper withBorder radius="2xl" className="glass" p="md"> <Modal
<Group mb="md" grow> opened={updateModalOpened}
onClose={closeUpdateModal}
title={<Text fw={700} size="lg">Update Bug Status</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
required
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
<Textarea
label="Update Note (Optional)"
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
minRows={3}
value={updateForm.description}
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdating}
onClick={handleUpdateStatus}
>
Save Changes
</Button>
</Stack>
</Modal>
<Modal
opened={feedbackModalOpened}
onClose={closeFeedbackModal}
title={<Text fw={700} size="lg">Developer Feedback</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
data-autofocus
label="Feedback / Note"
placeholder="Explain the issue, root cause, or resolution..."
required
minRows={4}
value={feedbackForm.feedBack}
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdatingFeedback}
onClick={handleUpdateFeedback}
>
Save Feedback
</Button>
</Stack>
</Modal>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
label="Description"
placeholder="What happened? Describe the error in detail..."
required
minRows={3}
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
/>
<SimpleGrid cols={2}>
<Select
label="Application"
data={[
{ value: 'desa-plus', label: 'Desa+' },
{ value: 'hipmi', label: 'Hipmi' },
]}
value={createForm.app}
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
/>
<Select
label="Source"
data={[
{ value: 'USER', label: 'User' },
{ value: 'QC', label: 'QC' },
{ value: 'SYSTEM', label: 'System' },
]}
value={createForm.source}
onChange={(val) => setCreateForm({ ...createForm, source: val as any })}
/>
</SimpleGrid>
<SimpleGrid cols={2}>
<TextInput
label="Version"
placeholder="e.g. 2.4.1"
required
value={createForm.affectedVersion}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
/>
<Select
label="Initial Status"
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
]}
value={createForm.status}
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
/>
</SimpleGrid>
<SimpleGrid cols={2}>
<TextInput
label="Device"
placeholder="e.g. iPhone 13, Windows 11 PC"
required
value={createForm.device}
onChange={(e) => setCreateForm({ ...createForm, device: e.target.value })}
/>
<TextInput
label="OS"
placeholder="e.g. iOS 15.4, Windows 11"
required
value={createForm.os}
onChange={(e) => setCreateForm({ ...createForm, os: e.target.value })}
/>
</SimpleGrid>
<TextInput <TextInput
placeholder="Search error message, village, or stack trace..." label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
/>
<Textarea
label="Stack Trace (Optional)"
placeholder="Paste code or error logs here..."
style={{ fontFamily: 'monospace' }}
minRows={2}
value={createForm.stackTrace}
onChange={(e) => setCreateForm({ ...createForm, stackTrace: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateBug}
>
Submit Error Report
</Button>
</Stack>
</Modal>
<Paper withBorder radius="2xl" className="glass" p="md">
<SimpleGrid cols={{ base: 1, sm: 3 }} mb="md">
<TextInput
placeholder="Search description, device, os..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md" radius="md"
/> />
<Select <Select
placeholder="Severity" placeholder="Status"
data={['Critical', 'High', 'Medium', 'Low']} data={[
leftSection={<TbFilter size={16} />} { value: 'all', label: 'All Status' },
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={status}
onChange={(val) => setStatus(val || 'all')}
radius="md" radius="md"
clearable
/> />
</Group> <Group justify="flex-end">
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => { setSearch(''); setStatus('all') }}>
Reset
</Button>
</Group>
</SimpleGrid>
<Accordion variant="separated" radius="xl"> {isLoading ? (
{mockErrors.map((error) => ( <Stack align="center" py="xl">
<Accordion.Item <Loader size="lg" type="dots" />
key={error.id} <Text size="sm" c="dimmed">Loading error reports...</Text>
value={error.id.toString()} </Stack>
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }} ) : bugs.length === 0 ? (
> <Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
<Accordion.Control> <TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
<Group wrap="nowrap"> <Text fw={600}>No error reports found</Text>
<ThemeIcon <Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'} </Paper>
variant="light" ) : (
size="lg" <Accordion variant="separated" radius="xl">
radius="md" {bugs.map((bug: any) => (
> <Accordion.Item
<TbAlertTriangle size={20} /> key={bug.id}
</ThemeIcon> value={bug.id}
<Box style={{ flex: 1 }}> style={{
<Group justify="space-between"> border: '1px solid var(--mantine-color-default-border)',
<Text size="sm" fw={600} lineClamp={1}>{error.title}</Text> background: 'var(--mantine-color-default)',
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs"> marginBottom: '12px',
{error.severity.toUpperCase()} }}
</Badge> >
</Group> <Accordion.Control>
<Group gap="md"> <Group wrap="nowrap">
<Text size="xs" c="dimmed">{error.time} v{error.version}</Text> <ThemeIcon
<Group gap={4} visibleFrom="sm"> color={
<TbUserCheck size={12} color="gray" /> bug.status === 'OPEN'
<Text size="xs" c="dimmed">{error.users} Users Affected</Text> ? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="light"
size="lg"
radius="md"
>
<TbAlertTriangle size={20} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Group justify="space-between">
<Text size="sm" fw={600} lineClamp={1}>
{bug.description}
</Text>
<Badge
color={
bug.status === 'OPEN'
? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="dot"
size="xs"
>
{bug.status}
</Badge>
</Group> </Group>
</Group> <Group gap="md">
</Box> <Text size="xs" c="dimmed">
</Group> {new Date(bug.createdAt).toLocaleString()} {bug.app?.toUpperCase()} v{bug.affectedVersion}
</Accordion.Control> </Text>
<Accordion.Panel>
<Stack gap="md" py="xs">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
<Text size="sm" fw={500}>{error.message}</Text>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
<Text size="xs" fw={500}>{error.device}</Text>
</Group> </Group>
</Box> </Box>
</SimpleGrid>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
{error.stackTrace}
</Code>
</Box>
<Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button>
<Button variant="light" size="compact-xs" color="teal" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
</Group> </Group>
</Stack> </Accordion.Control>
</Accordion.Panel> <Accordion.Panel>
</Accordion.Item> <Stack gap="lg" py="xs">
))} {/* Device Info */}
</Accordion> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
<TbDeviceDesktop size={14} color="gray" />
) : (
<TbDeviceMobile size={14} color="gray" />
)}
<Text size="xs" fw={500}>{bug.device} ({bug.os})</Text>
</Group>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
</Box>
</SimpleGrid>
{/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{bug.user && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
<Group gap="xs">
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
{bug.user.name?.charAt(0).toUpperCase()}
</Avatar>
<Text size="sm">{bug.user.name}</Text>
</Group>
</Box>
)}
{bug.feedBack && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
</Box>
)}
</SimpleGrid>
)}
{/* Stack Trace */}
{bug.stackTrace && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Code
block
color="red"
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
fontSize: '11px',
border: '1px solid var(--mantine-color-default-border)',
}}
>
{bug.stackTrace}
</Code>
</Box>
)}
{/* Images */}
{bug.images && bug.images.length > 0 && (
<Box>
<Group gap="xs" mb={8}>
<TbPhoto size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ATTACHED IMAGES ({bug.images.length})</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}
</SimpleGrid>
</Box>
)}
{/* Logs / History */}
{bug.logs && bug.logs.length > 0 && (
<Box>
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
<Group gap="xs">
<TbHistory size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
</Group>
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => toggleLogs(bug.id)}
>
{showLogs[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group>
<Collapse in={showLogs[bug.id]}>
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
{bug.logs.map((log: any) => (
<Timeline.Item
key={log.id}
bullet={
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
}
title={<Text size="sm" fw={600}>{log.status}</Text>}
>
<Text size="xs" c="dimmed" mb={4}>
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
</Text>
<Text size="sm">{log.description}</Text>
</Timeline.Item>
))}
</Timeline>
</Collapse>
</Box>
)}
<Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue" onClick={() => {
setSelectedBugId(bug.id)
setFeedbackForm({ feedBack: bug.feedBack || '' })
openFeedbackModal()
}}>Developer Feedback</Button>
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
setSelectedBugId(bug.id)
setUpdateForm({ status: bug.status, description: '' })
openUpdateModal()
}}>Update Status</Button>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)}
{totalPages > 1 && (
<Group justify="center" mt="xl">
<Pagination total={totalPages} value={page} onChange={setPage} radius="xl" />
</Group>
)}
</Paper> </Paper>
</Stack> </Stack>
) )

View File

@@ -2,10 +2,12 @@ import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { API_URLS } from '@/frontend/config/api' import { API_URLS } from '@/frontend/config/api'
import { import {
Accordion, Accordion,
Avatar,
Badge, Badge,
Box, Box,
Button, Button,
Code, Code,
Collapse,
Container, Container,
Group, Group,
Image, Image,
@@ -52,6 +54,12 @@ function ListErrorsPage() {
const [app, setApp] = useState('all') const [app, setApp] = useState('all')
const [status, setStatus] = useState('all') const [status, setStatus] = useState('all')
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const toggleLogs = (bugId: string) => {
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
}
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, search, app, status }], queryKey: ['bugs', { page, search, app, status }],
queryFn: () => queryFn: () =>
@@ -63,7 +71,7 @@ function ListErrorsPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
description: '', description: '',
app: 'desa_plus', app: 'desa-plus',
status: 'OPEN', status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
@@ -73,6 +81,94 @@ function ListErrorsPage() {
imageUrl: '', imageUrl: '',
}) })
// Update Status Modal Logic
const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [selectedBugId, setSelectedBugId] = useState<string | null>(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: <TbCircleCheck size={18} />,
})
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: <TbCircleX size={18} />,
})
} 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: <TbCircleCheck size={18} />,
})
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: <TbCircleX size={18} />,
})
} finally {
setIsUpdating(false)
}
}
const handleCreateBug = async () => { const handleCreateBug = async () => {
if (!createForm.description || !createForm.affectedVersion || !createForm.device || !createForm.os) { if (!createForm.description || !createForm.affectedVersion || !createForm.device || !createForm.os) {
notifications.show({ notifications.show({
@@ -95,12 +191,12 @@ function ListErrorsPage() {
await fetch(API_URLS.createLog(), { await fetch(API_URLS.createLog(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `Report bug baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` }) body: JSON.stringify({ type: 'CREATE', message: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
}).catch(console.error) }).catch(console.error)
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Bug report has been created.', message: 'Error report has been created.',
color: 'teal', color: 'teal',
icon: <TbCircleCheck size={18} />, icon: <TbCircleCheck size={18} />,
}) })
@@ -108,7 +204,7 @@ function ListErrorsPage() {
close() close()
setCreateForm({ setCreateForm({
description: '', description: '',
app: 'desa_plus', app: 'desa-plus',
status: 'OPEN', status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
@@ -118,7 +214,7 @@ function ListErrorsPage() {
imageUrl: '', imageUrl: '',
}) })
} else { } else {
throw new Error('Failed to create bug report') throw new Error('Failed to create error report')
} }
} catch (e) { } catch (e) {
notifications.show({ notifications.show({
@@ -142,10 +238,10 @@ function ListErrorsPage() {
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Stack gap={0}> <Stack gap={0}>
<Title order={2} className="gradient-text"> <Title order={2} className="gradient-text">
Bug Reports Error Reports
</Title> </Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Centralized bug tracking and analysis for all applications. Centralized error tracking and analysis for all applications.
</Text> </Text>
</Stack> </Stack>
<Group> <Group>
@@ -155,7 +251,7 @@ function ListErrorsPage() {
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
onClick={open} onClick={open}
> >
Report Bug Report Error
</Button> </Button>
{/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}> {/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}>
Generate Report Generate Report
@@ -163,10 +259,83 @@ function ListErrorsPage() {
</Group> </Group>
</Group> </Group>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
title={<Text fw={700} size="lg">Update Bug Status</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
required
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
<Textarea
label="Update Note (Optional)"
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
minRows={3}
value={updateForm.description}
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdating}
onClick={handleUpdateStatus}
>
Save Changes
</Button>
</Stack>
</Modal>
<Modal
opened={feedbackModalOpened}
onClose={closeFeedbackModal}
title={<Text fw={700} size="lg">Developer Feedback</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
data-autofocus
label="Feedback / Note"
placeholder="Explain the issue, root cause, or resolution..."
required
minRows={4}
value={feedbackForm.feedBack}
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdatingFeedback}
onClick={handleUpdateFeedback}
>
Save Feedback
</Button>
</Stack>
</Modal>
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
title={<Text fw={700} size="lg">Report New Bug</Text>} title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl" radius="xl"
size="lg" size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
@@ -174,7 +343,7 @@ function ListErrorsPage() {
<Stack gap="md"> <Stack gap="md">
<Textarea <Textarea
label="Description" label="Description"
placeholder="What happened? Describe the bug in detail..." placeholder="What happened? Describe the error in detail..."
required required
minRows={3} minRows={3}
value={createForm.description} value={createForm.description}
@@ -264,7 +433,7 @@ function ListErrorsPage() {
loading={isSubmitting} loading={isSubmitting}
onClick={handleCreateBug} onClick={handleCreateBug}
> >
Submit Bug Report Submit Error Report
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
@@ -292,7 +461,7 @@ function ListErrorsPage() {
<Select <Select
placeholder="Status" placeholder="Status"
data={[ data={[
{ value: 'all', label: 'All Statuses' }, { value: 'all', label: 'All Status' },
{ value: 'OPEN', label: 'Open' }, { value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' }, { value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' }, { value: 'IN_PROGRESS', label: 'In Progress' },
@@ -319,7 +488,7 @@ function ListErrorsPage() {
) : bugs.length === 0 ? ( ) : bugs.length === 0 ? (
<Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}> <Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
<TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} /> <TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
<Text fw={600}>No bugs found</Text> <Text fw={600}>No error reports found</Text>
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text> <Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
</Paper> </Paper>
) : ( ) : (
@@ -398,6 +567,29 @@ function ListErrorsPage() {
</Box> </Box>
</SimpleGrid> </SimpleGrid>
{/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{bug.user && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
<Group gap="xs">
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
{bug.user.name?.charAt(0).toUpperCase()}
</Avatar>
<Text size="sm">{bug.user.name}</Text>
</Group>
</Box>
)}
{bug.feedBack && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
</Box>
)}
</SimpleGrid>
)}
{/* Stack Trace */} {/* Stack Trace */}
{bug.stackTrace && ( {bug.stackTrace && (
<Box> <Box>
@@ -427,7 +619,7 @@ function ListErrorsPage() {
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => ( {bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}> <Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Image src={img.imageUrl} alt="Bug screenshot" height={100} fit="cover" /> <Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper> </Paper>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -437,32 +629,52 @@ function ListErrorsPage() {
{/* Logs / History */} {/* Logs / History */}
{bug.logs && bug.logs.length > 0 && ( {bug.logs && bug.logs.length > 0 && (
<Box> <Box>
<Group gap="xs" mb={12}> <Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
<TbHistory size={16} color="gray" /> <Group gap="xs">
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG</Text> <TbHistory size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
</Group>
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => toggleLogs(bug.id)}
>
{showLogs[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group> </Group>
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2}> <Collapse in={showLogs[bug.id]}>
{bug.logs.map((log: any) => ( <Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
<Timeline.Item {bug.logs.map((log: any) => (
key={log.id} <Timeline.Item
bullet={ key={log.id}
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge> bullet={
} <Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
title={<Text size="sm" fw={600}>{log.status}</Text>} }
> title={<Text size="sm" fw={600}>{log.status}</Text>}
<Text size="xs" c="dimmed" mb={4}> >
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'} <Text size="xs" c="dimmed" mb={4}>
</Text> {new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
<Text size="sm">{log.description}</Text> </Text>
</Timeline.Item> <Text size="sm">{log.description}</Text>
))} </Timeline.Item>
</Timeline> ))}
</Timeline>
</Collapse>
</Box> </Box>
)} )}
<Group justify="flex-end" pt="sm"> <Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button> <Button variant="light" size="compact-xs" color="blue" onClick={() => {
<Button variant="light" size="compact-xs" color="teal">Update Status</Button> setSelectedBugId(bug.id)
setFeedbackForm({ feedBack: bug.feedBack || '' })
openFeedbackModal()
}}>Developer Feedback</Button>
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
setSelectedBugId(bug.id)
setUpdateForm({ status: bug.status, description: '' })
openUpdateModal()
}}>Update Status</Button>
</Group> </Group>
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>

View File

@@ -36,12 +36,6 @@ export const Route = createFileRoute('/dashboard')({
component: DashboardPage, component: DashboardPage,
}) })
const recentErrors = [
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
]
function DashboardPage() { function DashboardPage() {
const { data: sessionData } = useSession() const { data: sessionData } = useSession()
const user = sessionData?.user const user = sessionData?.user
@@ -56,6 +50,20 @@ function DashboardPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch('/api/apps').then((r) => r.json()),
}) })
const { data: recentErrors = [], isLoading: recentErrorsLoading } = useQuery({
queryKey: ['dashboard', 'recent-errors'],
queryFn: () => fetch('/api/dashboard/recent-errors').then((r) => r.json()),
})
const formatTimeAgo = (dateStr: string) => {
const diff = new Date().getTime() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes || 1} mins ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hours ago`
return `${Math.floor(hours / 24)} days ago`
}
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -86,21 +94,21 @@ function DashboardPage() {
value={stats?.totalApps || 0} value={stats?.totalApps || 0}
icon={TbApps} icon={TbApps}
color="brand-blue" color="brand-blue"
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }} // trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
/> />
<StatsCard <StatsCard
title="New Errors" title="New Errors"
value={stats?.newErrors || 0} value={stats?.newErrors || 0}
icon={TbMessageReport} icon={TbMessageReport}
color="brand-purple" color="brand-purple"
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }} // trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
/> />
<StatsCard <StatsCard
title="Active Users" title="Users"
value={stats?.activeUsers || 0} value={stats?.activeUsers || 0}
icon={TbUsers} icon={TbUsers}
color="teal" color="teal"
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }} // trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
/> />
</SimpleGrid> </SimpleGrid>
)} )}
@@ -124,7 +132,7 @@ function DashboardPage() {
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Title order={3}>Recent Error Reports</Title> <Title order={3}>Recent Error Reports</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}> <Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/bug-reports">
View All Errors View All Errors
</Button> </Button>
</Group> </Group>
@@ -141,23 +149,35 @@ function DashboardPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{recentErrors.map((error) => ( {recentErrorsLoading ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
<Loader size="sm" type="dots" />
</Table.Td>
</Table.Tr>
) : recentErrors.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
<Text c="dimmed" size="sm">No recent errors found.</Text>
</Table.Td>
</Table.Tr>
) : recentErrors.map((error: any) => (
<Table.Tr key={error.id}> <Table.Tr key={error.id}>
<Table.Td> <Table.Td>
<Text fw={600} size="sm">{error.app}</Text> <Text fw={600} size="sm" style={{ textTransform: 'uppercase' }}>{error.app}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" c="dimmed">{error.message}</Text> <Text size="sm" c="dimmed" lineClamp={1}>{error.message}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="light" color="gray">v{error.version}</Badge> <Badge variant="light" color="gray">v{error.version}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{error.time}</Text> <Text size="xs" c="dimmed">{formatTimeAgo(error.time)}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'} color={error.severity === 'OPEN' ? 'red' : error.severity === 'IN_PROGRESS' || error.severity === 'ON_HOLD' ? 'orange' : 'yellow'}
variant="dot" variant="dot"
> >
{error.severity.toUpperCase()} {error.severity.toUpperCase()}

View File

@@ -174,7 +174,7 @@ function GlobalLogsPage() {
</Group> </Group>
{/* Timeline Content */} {/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}> <Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{isLoading ? ( {isLoading ? (
<Center py="xl"> <Center py="xl">
<Text c="dimmed">Loading logs...</Text> <Text c="dimmed">Loading logs...</Text>
@@ -190,7 +190,7 @@ function GlobalLogsPage() {
fw={700} fw={700}
c="dimmed" c="dimmed"
mt={groupIndex > 0 ? "xl" : 0} mt={groupIndex > 0 ? "xl" : 0}
mb="lg" mb="md"
style={{ textTransform: 'uppercase' }} style={{ textTransform: 'uppercase' }}
> >
{group.date} {group.date}

View File

@@ -18,9 +18,14 @@ import {
List, List,
Divider, Divider,
Pagination, Pagination,
Modal,
Select,
PasswordInput,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { import {
TbPlus, TbPlus,
TbSearch, TbSearch,
@@ -30,6 +35,7 @@ import {
TbShieldCheck, TbShieldCheck,
TbAccessPoint, TbAccessPoint,
TbCircleCheck, TbCircleCheck,
TbCircleX,
TbClock, TbClock,
TbApps, TbApps,
} from 'react-icons/tb' } from 'react-icons/tb'
@@ -80,14 +86,131 @@ function UsersPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [search]) }, [search])
const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher) const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
const { data: response, isLoading } = useSWR( const { data: response, isLoading, mutate: mutateOperators } = useSWR(
API_URLS.getOperators(page, debouncedSearch), API_URLS.getOperators(page, debouncedSearch),
fetcher fetcher
) )
const operators = response?.data || [] const operators = response?.data || []
// ── Create User Modal ──
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
const [isCreating, setIsCreating] = useState(false)
const [createForm, setCreateForm] = useState({
name: '',
email: '',
password: '',
role: 'USER',
})
const handleCreateUser = async () => {
if (!createForm.name || !createForm.email || !createForm.password) {
notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
return
}
setIsCreating(true)
try {
const res = await fetch(API_URLS.createOperator(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
closeCreate()
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to create user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsCreating(false)
}
}
// ── Edit User Modal ──
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [isEditing, setIsEditing] = useState(false)
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({
name: '',
email: '',
role: '',
})
const handleOpenEdit = (user: any) => {
setEditingUserId(user.id)
setEditForm({ name: user.name, email: user.email, role: user.role })
openEdit()
}
const handleEditUser = async () => {
if (!editingUserId || !editForm.name || !editForm.email) return
setIsEditing(true)
try {
const res = await fetch(API_URLS.editOperator(editingUserId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
closeEdit()
} else {
throw new Error('Failed to update user')
}
} catch (e) {
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsEditing(false)
}
}
// ── Delete User ──
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deletingUser, setDeletingUser] = useState<any>(null)
const handleOpenDelete = (user: any) => {
setDeletingUser(user)
openDelete()
}
const handleDeleteUser = async () => {
if (!deletingUser) return
setIsDeleting(true)
try {
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
method: 'DELETE',
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been deleted.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
closeDelete()
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to delete user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsDeleting(false)
}
}
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -131,6 +254,7 @@ function UsersPage() {
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
radius="md" radius="md"
onClick={openCreate}
> >
Add New User Add New User
</Button> </Button>
@@ -183,10 +307,10 @@ function UsersPage() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
<ActionIcon variant="light" size="sm" color="blue"> <ActionIcon variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} /> <TbPencil size={14} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" size="sm" color="red"> <ActionIcon variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} /> <TbTrash size={14} />
</ActionIcon> </ActionIcon>
</Group> </Group>
@@ -256,7 +380,131 @@ function UsersPage() {
</Tabs> </Tabs>
</Stack> </Stack>
</Container> </Container>
{/* Create User Modal */}
<Modal
opened={createOpened}
onClose={closeCreate}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
/>
<PasswordInput
label="Password"
placeholder="Enter password"
required
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'USER', label: 'User' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
]}
value={createForm.role}
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isCreating}
onClick={handleCreateUser}
>
Create User
</Button>
</Stack>
</Modal>
{/* Edit User Modal */}
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'USER', label: 'User' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
]}
value={editForm.role}
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isEditing}
onClick={handleEditUser}
>
Save Changes
</Button>
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={<Text fw={700} size="lg">Delete User</Text>}
radius="xl"
size="sm"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
</Text>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeDelete}>
Cancel
</Button>
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
</DashboardLayout> </DashboardLayout>
) )
} }