Files
monitoring-app/src/frontend/routes/bug-reports.tsx
amaliadwiy f368e1d31b feat: bug statistics + village detail dashboard enhancement
- Tambah GET /api/bugs/stats dengan summary cards & chart trend/bugs per app
- Tambah date range picker di village activity chart
- Tambah tabel Recent Activity (action + description) di village detail
- Update API graph-log-villages support dateFrom/dateTo custom range
2026-05-25 15:00:33 +08:00

899 lines
36 KiB
TypeScript

import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { API_URLS } from '@/frontend/config/api'
import { AreaChart, BarChart } from '@mantine/charts'
import {
Accordion,
Avatar,
Badge,
Box,
Button,
Code,
Collapse,
Container,
FileInput,
Group,
Image,
Loader,
Modal,
Pagination,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
Timeline,
Title,
Tooltip,
} from '@mantine/core'
import { useDebouncedValue, useDisclosure } from '@mantine/hooks'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import { notifications } from '@mantine/notifications'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import {
TbAlertTriangle,
TbBug,
TbChartBar,
TbCircleCheck,
TbCircleX,
TbClock,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
TbHistory,
TbPhoto,
TbPlus,
TbSearch,
TbTrendingUp,
} from 'react-icons/tb'
import useSWR from 'swr'
export const Route = createFileRoute('/bug-reports')({
component: ListErrorsPage,
})
const STATUS_COLOR: Record<string, string> = {
OPEN: 'red',
IN_PROGRESS: 'blue',
ON_HOLD: 'orange',
RESOLVED: 'teal',
RELEASED: 'green',
CLOSED: 'gray',
}
const STATUS_LABEL: Record<string, string> = {
OPEN: 'Open',
ON_HOLD: 'On Hold',
IN_PROGRESS: 'In Progress',
RESOLVED: 'Resolved',
RELEASED: 'Released',
CLOSED: 'Closed',
}
function ListErrorsPage() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [app, setApp] = useState('all')
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const [bugRange, setBugRange] = useState<7 | 30 | 90>(7)
const [debouncedSearch] = useDebouncedValue(search, 400)
useEffect(() => {
if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) {
setSearchQuery(debouncedSearch)
setPage(1)
}
}, [debouncedSearch])
useEffect(() => { setPage(1) }, [app, status, source, dateRange])
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
const toggleLogs = (bugId: string) => setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
const toggleStackTrace = (bugId: string) => setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, searchQuery, app, status, source, dateFrom, dateTo }],
queryFn: () => fetch(API_URLS.getBugs(page, searchQuery, app, status, source, dateFrom, dateTo)).then((r) => r.json()),
})
const { data: bugStats } = useSWR(API_URLS.getBugStats(bugRange), (url: string) => fetch(url).then((r) => r.json()))
const { data: appsList } = useQuery({
queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({
description: '',
app: 'desa-plus',
source: 'USER',
affectedVersion: '',
device: '',
os: '',
stackTrace: '',
})
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: '' })
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()
}
} catch {
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()
}
} catch {
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 imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Failed to upload image')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `New error report added: ${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()
setImageFiles([])
setCreateForm({ description: '', app: 'desa-plus', source: 'USER', affectedVersion: '', device: '', os: '', stackTrace: '' })
} else {
throw new Error()
}
} catch {
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="flex-start">
<Stack gap={4}>
<Title order={2} className="gradient-text">Error Reports</Title>
<Text size="sm" c="dimmed">
Centralized error tracking and analysis for all applications.
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
size="sm"
onClick={open}
>
Report Error
</Button>
</Group>
{/* Bug Statistics Section */}
{bugStats && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
<SummaryCard
title="Total Bugs"
value={bugStats.totalBugs?.toLocaleString() ?? '0'}
icon={TbBug}
color="brand-blue"
/>
<SummaryCard
title="Open Bugs"
value={bugStats.openBugs?.toLocaleString() ?? '0'}
icon={TbAlertTriangle}
color="red"
isError={bugStats.openBugs > 0}
/>
<SummaryCard
title="Avg Resolution Time"
value={`${bugStats.avgResolutionHours ?? 0}h`}
icon={TbClock}
color="orange"
/>
<SummaryCard
title="Resolution Rate"
value={`${bugStats.resolutionRate ?? 0}%`}
icon={TbTrendingUp}
color="teal"
/>
</SimpleGrid>
)}
{bugStats && (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Paper withBorder radius="2xl" className="glass" p="md">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="brand-blue">
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Bugs per Application</Text>
</Stack>
</Group>
<BarChart
h={220}
data={(bugStats.byApp || []).map((item: { appId: string; count: number }) => ({
...item,
appId: item.appId.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
}))}
dataKey="appId"
series={[{ name: 'count', color: 'blue.6' }]}
withTooltip
tickLine="none"
gridAxis="x"
barProps={{
radius: [8, 8, 0, 0],
fill: 'url(#bugBarGradient)',
}}
xAxisProps={{
tick: { fontSize: 12, fill: '#909296' },
}}
tooltipProps={{
content: ({ active, payload }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
{payload[0]?.payload?.appId}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
</div>
</div>
)
},
}}
>
<defs>
<linearGradient id="bugBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0.8} />
</linearGradient>
</defs>
</BarChart>
</Paper>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="violet">
<TbTrendingUp size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Bug Trend</Text>
<Text size="xs" c="dimmed">Last {bugRange} days</Text>
</Stack>
</Group>
<Group gap={4}>
{([7, 30, 90] as const).map((r) => (
<Button
key={r}
size="compact-xs"
variant={bugRange === r ? 'filled' : 'subtle'}
color="violet"
radius="md"
onClick={() => setBugRange(r)}
>
{r === 7 ? '7D' : r === 30 ? '1M' : '3M'}
</Button>
))}
</Group>
</Group>
<AreaChart
h={220}
data={bugStats.trend || []}
dataKey="date"
series={[{ name: 'count', color: '#7C3AED' }]}
curveType="monotone"
withTooltip
tickLine="none"
gridAxis="x"
fillOpacity={0.3}
xAxisProps={{
interval: bugRange === 7 ? 0 : bugRange === 30 ? 4 : 9,
tick: { fontSize: 10, fill: '#909296' },
angle: bugRange === 7 ? 0 : -45,
textAnchor: 'end',
height: bugRange === 7 ? 30 : 60,
}}
tooltipProps={{
content: ({ active, payload }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
{payload[0]?.payload?.date}
</div>
<div style={{ fontSize: '11px', color: '#7C3AED' }}>
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
</div>
</div>
)
},
}}
styles={{
root: {
'.recharts-area-curve': {
strokeWidth: 2.5,
filter: 'drop-shadow(0 3px 6px rgba(124, 58, 237, 0.3))',
},
},
}}
/>
</Paper>
</SimpleGrid>
)}
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="md"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image src={previewImage} alt="Preview" fit="contain" style={{ maxHeight: '85vh', width: '100%' }} />
)}
</Modal>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
title={<Text fw={700} size="lg">Update Bug Status</Text>}
radius="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Select
label="New Status"
placeholder="Select a status"
required
data={Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label }))}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
<Textarea
label="Update Note (Optional)"
placeholder="e.g. Fixed in commit abc123 / 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="md"
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({ 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(); setImageFiles([]) }}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="md"
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={appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []}
value={createForm.app}
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
placeholder="Select application"
disabled={!appsList}
/>
<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>
<TextInput
label="Affected Version"
placeholder="e.g. 2.4.1"
required
value={createForm.affectedVersion}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
/>
<SimpleGrid cols={2}>
<TextInput
label="Device"
placeholder="e.g. iPhone 13, Windows 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>
<FileInput
label="Screenshots (Optional)"
placeholder="Click to upload images..."
accept="image/*"
leftSection={<TbPhoto size={16} />}
description="Max 3 images · 5 MB each · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maximum 3 images allowed.', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/>
<Textarea
label="Stack Trace (Optional)"
placeholder="Paste error logs or stack trace 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: 2, md: 3 }} mb="sm">
<TextInput
label="Search"
placeholder="Description, device, OS..."
leftSection={<TbSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
size="sm"
/>
<Select
label="Application"
size="sm"
data={[
{ value: 'all', label: 'All Applications' },
...(appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []),
]}
value={app}
onChange={(val) => setApp(val || 'all')}
radius="md"
disabled={!appsList}
/>
<Select
label="Status"
size="sm"
data={[
{ value: 'all', label: 'All Status' },
...Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label })),
]}
value={status}
onChange={(val) => setStatus(val || 'all')}
radius="md"
/>
<Select
label="Source"
size="sm"
data={[
{ value: 'all', label: 'All Sources' },
{ value: 'QC', label: 'QC' },
{ value: 'SYSTEM', label: 'System' },
{ value: 'USER', label: 'User' },
]}
value={source}
onChange={(val) => setSource(val || 'all')}
radius="md"
/>
<DatePickerInput
type="range"
label="Date Range"
placeholder="Pick date range"
size="sm"
radius="md"
value={dateRange}
onChange={setDateRange}
clearable
/>
<Stack justify="flex-end">
<Button
variant="filled"
color="violet"
size="sm"
onClick={() => { setSearch(''); setApp('all'); setStatus('all'); setSource('all'); setDateRange([null, null]) }}
>
Reset Filters
</Button>
</Stack>
</SimpleGrid>
{isLoading ? (
<Stack align="center" py="xl">
<Loader size="md" type="dots" />
</Stack>
) : bugs.length === 0 ? (
<Stack align="center" py="xl" gap="xs">
<TbBug size={40} style={{ opacity: 0.25 }} />
<Text fw={600} size="sm">No error reports found</Text>
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
</Stack>
) : (
<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: 12,
}}
>
<Accordion.Control>
<Group wrap="nowrap">
<ThemeIcon
color={STATUS_COLOR[bug.status] ?? 'gray'}
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={STATUS_COLOR[bug.status] ?? 'gray'}
variant="dot"
size="sm"
>
{STATUS_LABEL[bug.status] ?? bug.status}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{dayjs(bug.createdAt).format('D MMM YYYY, HH:mm')} · {bug.appId?.toUpperCase()} · v{bug.affectedVersion}
</Text>
</Box>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="lg" py="xs">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4} tt="uppercase">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} tt="uppercase">Source</Text>
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
</Box>
</SimpleGrid>
{(bug.user || bug.feedBack) && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{bug.user && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4} tt="uppercase">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} tt="uppercase">Developer Feedback</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
</Box>
)}
</SimpleGrid>
)}
{bug.stackTrace && (
<Box>
<Group justify="space-between" mb={showStackTrace[bug.id] ? 8 : 0}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">Stack Trace</Text>
<Button variant="subtle" size="compact-xs" color="gray" onClick={() => toggleStackTrace(bug.id)}>
{showStackTrace[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group>
<Collapse in={showStackTrace[bug.id]}>
<Code
block
color="red"
style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: 11, border: '1px solid var(--mantine-color-default-border)' }}
>
{bug.stackTrace}
</Code>
</Collapse>
</Box>
)}
{bug.images && bug.images.length > 0 && (
<Box>
<Group gap="xs" mb={8}>
<TbPhoto size={14} color="gray" />
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Attached Images ({bug.images.length})
</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Tooltip key={img.id} label="Click to preview" withArrow>
<Paper
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
</Tooltip>
))}
</SimpleGrid>
</Box>
)}
{bug.logs && bug.logs.length > 0 && (
<Box>
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
<Group gap="xs">
<TbHistory size={14} color="gray" />
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
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={STATUS_COLOR[log.status] ?? 'blue'}> </Badge>
}
title={
<Text size="sm" fw={600}>
{STATUS_LABEL[log.status] ?? log.status}
</Text>
}
>
<Text size="xs" c="dimmed" mb={4}>
{dayjs(log.createdAt).format('D MMM YYYY, HH:mm')} · {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-sm"
color="blue"
onClick={() => {
setSelectedBugId(bug.id)
setFeedbackForm({ feedBack: bug.feedBack || '' })
openFeedbackModal()
}}
>
Developer Feedback
</Button>
<Button
variant="light"
size="compact-sm"
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} size="sm" radius="xl" />
</Group>
)}
</Paper>
</Stack>
</Container>
</DashboardLayout>
)
}