upd: bug list
Deskripsi: - tampilan list bug error - tampilan tambah bug - connected to database; list and create No Issues
This commit is contained in:
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;
|
||||||
@@ -125,13 +125,13 @@ model BugImage {
|
|||||||
model BugLog {
|
model BugLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bugId String
|
bugId String
|
||||||
userId String
|
userId String?
|
||||||
status BugStatus
|
status BugStatus
|
||||||
description String
|
description String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
|
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")
|
@@map("bug_log")
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/app.ts
90
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 ───────────────────────────────────
|
// ─── Example API ───────────────────────────────────
|
||||||
.get('/api/hello', () => ({
|
.get('/api/hello', () => ({
|
||||||
message: 'Hello, world!',
|
message: 'Hello, world!',
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import {
|
|||||||
TbSun,
|
TbSun,
|
||||||
TbMoon,
|
TbMoon,
|
||||||
TbUser,
|
TbUser,
|
||||||
TbHistory
|
TbHistory,
|
||||||
|
TbBug
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -52,6 +53,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: 'Users', icon: TbUser, to: '/users' },
|
{ label: 'Users', icon: TbUser, to: '/users' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ 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`,
|
||||||
|
getBugs: (page: number, search: string, app: string, status: string) =>
|
||||||
|
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
||||||
|
createBug: () => `/api/bugs`,
|
||||||
}
|
}
|
||||||
|
|||||||
478
src/frontend/routes/bug-reports.tsx
Normal file
478
src/frontend/routes/bug-reports.tsx
Normal file
@@ -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: <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 bug 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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container size="xl" py="lg">
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Title order={2} className="gradient-text">
|
||||||
|
Bug Reports
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Centralized bug tracking and analysis for all applications.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
leftSection={<TbPlus size={18} />}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
Report Bug
|
||||||
|
</Button>
|
||||||
|
{/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||||
|
Generate Report
|
||||||
|
</Button> */}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={<Text fw={700} size="lg">Report New Bug</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="What happened? Describe the bug 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
|
||||||
|
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 Bug Report
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 4 }} mb="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search description, device, os..."
|
||||||
|
leftSection={<TbSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Application"
|
||||||
|
data={[
|
||||||
|
{ value: 'all', label: 'All Applications' },
|
||||||
|
{ value: 'desa_plus', label: 'Desa+' },
|
||||||
|
{ value: 'hipmi', label: 'Hipmi' },
|
||||||
|
]}
|
||||||
|
value={app}
|
||||||
|
onChange={(val) => setApp(val || 'all')}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
data={[
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ 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"
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => {setSearch(''); setApp('all'); setStatus('all')}}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack align="center" py="xl">
|
||||||
|
<Loader size="lg" type="dots" />
|
||||||
|
<Text size="sm" c="dimmed">Loading error reports...</Text>
|
||||||
|
</Stack>
|
||||||
|
) : bugs.length === 0 ? (
|
||||||
|
<Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
|
||||||
|
<TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
|
||||||
|
<Text fw={600}>No bugs found</Text>
|
||||||
|
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Accordion variant="separated" radius="xl">
|
||||||
|
{bugs.map((bug: any) => (
|
||||||
|
<Accordion.Item
|
||||||
|
key={bug.id}
|
||||||
|
value={bug.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
background: 'var(--mantine-color-default)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
color={
|
||||||
|
bug.status === 'OPEN'
|
||||||
|
? '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 gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(bug.createdAt).toLocaleString()} • {bug.app.replace('_', ' ').toUpperCase()} • v{bug.affectedVersion}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="lg" py="xs">
|
||||||
|
{/* Device Info */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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="Bug screenshot" height={100} fit="cover" />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs / History */}
|
||||||
|
{bug.logs && bug.logs.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb={12}>
|
||||||
|
<TbHistory size={16} color="gray" />
|
||||||
|
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG</Text>
|
||||||
|
</Group>
|
||||||
|
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2}>
|
||||||
|
{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>
|
||||||
|
</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">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>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user