diff --git a/index.html b/index.html
index f7a73bd..fbe7d51 100644
--- a/index.html
+++ b/index.html
@@ -4,9 +4,10 @@
+
-
My App
+ Monitoring System
-
-
Loading...
+
+
Monitoring System
+
+
+
+
+
diff --git a/package.json b/package.json
index 754f818..5f305d8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bun-react-template",
- "version": "0.1.2",
+ "version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/app.ts b/src/app.ts
index 0d69b29..91e2f03 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -363,11 +363,8 @@ export function createApp() {
return apps.map((app) => ({
id: app.id,
name: app.name,
- status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
+ status: app.active ? 'active' : 'inactive',
errors: app.bugs.length,
- version: app.version ?? '-',
- minVersion: app.minVersion,
- maintenance: app.maintenance,
active: app.active,
urlApi: app.urlApi,
hasClientApiKey: !!app.clientApiKey,
@@ -400,11 +397,8 @@ export function createApp() {
return {
id: app.id,
name: app.name,
- status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
+ status: app.active ? 'active' : 'inactive',
errors: app.bugs.length,
- version: app.version ?? '-',
- minVersion: app.minVersion,
- maintenance: app.maintenance,
urlApi: app.urlApi,
totalBugs: app._count.bugs,
}
diff --git a/src/frontend/components/AppCard.tsx b/src/frontend/components/AppCard.tsx
index 7c5fcff..fca444a 100644
--- a/src/frontend/components/AppCard.tsx
+++ b/src/frontend/components/AppCard.tsx
@@ -1,6 +1,6 @@
-import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
+import { Avatar, Badge, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
import { Link } from '@tanstack/react-router'
-import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
+import { TbAlertTriangle, TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
interface AppCardProps {
id: string
@@ -12,8 +12,9 @@ interface AppCardProps {
maintenance?: boolean
}
-export function AppCard({ id, name, status, errors, version }: AppCardProps) {
- const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
+export function AppCard({ id, name, status, errors, version, maintenance }: AppCardProps) {
+ const statusColor = maintenance ? 'gray' : status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
+ const statusLabel = maintenance ? 'Maintenance' : status === 'active' ? 'Active' : status === 'warning' ? 'Warning' : 'Error'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
@@ -35,7 +36,7 @@ export function AppCard({ id, name, status, errors, version }: AppCardProps) {
},
})}
>
-
+
-
+
{name}
- {/* VERSION {version} */}
+ {/* v{version} */}
- {/*
- {status.toUpperCase()}
- */}
+
+ {statusLabel}
+
- {/*
-
-
-
-
- USER ADOPTION
-
- {users.toLocaleString()}
-
-
-
-
-
-
-
- 0 ? '#ef4444' : '#64748b'} />
- ERROR
-
- 0 ? 'red' : 'dimmed'}>{errors}
-
-
- */}
+
+ Open Errors
+ 0 ? 'red' : 'teal'}
+ variant="light"
+ size="sm"
+ leftSection={errors > 0 ? : undefined}
+ >
+ {errors > 0 ? errors : 'None'}
+
+
}
styles={{
@@ -97,7 +86,7 @@ export function AppCard({ id, name, status, errors, version }: AppCardProps) {
}
}}
>
- View
+ Open Dashboard
)
diff --git a/src/frontend/components/ErrorDataTable.tsx b/src/frontend/components/ErrorDataTable.tsx
index 7c73515..cd0c2f7 100644
--- a/src/frontend/components/ErrorDataTable.tsx
+++ b/src/frontend/components/ErrorDataTable.tsx
@@ -6,16 +6,21 @@ import {
Divider,
Drawer,
Group,
+ Loader,
Paper,
ScrollArea,
+ SimpleGrid,
Stack,
Table,
Text,
- Title
+ ThemeIcon,
+ Title,
+ Tooltip,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
+import dayjs from 'dayjs'
import { useState } from 'react'
import { TbBug, TbExternalLink, TbHistory, TbMessageReport } from 'react-icons/tb'
@@ -23,6 +28,23 @@ export interface ErrorDataTableProps {
appId?: string
}
+const STATUS_COLOR: Record = {
+ OPEN: 'red',
+ IN_PROGRESS: 'blue',
+ ON_HOLD: 'orange',
+ RESOLVED: 'teal',
+ RELEASED: 'green',
+ CLOSED: 'gray',
+}
+const STATUS_LABEL: Record = {
+ OPEN: 'Open',
+ ON_HOLD: 'On Hold',
+ IN_PROGRESS: 'In Progress',
+ RESOLVED: 'Resolved',
+ RELEASED: 'Released',
+ CLOSED: 'Closed',
+}
+
export function ErrorDataTable({ appId }: ErrorDataTableProps) {
const [opened, { open, close }] = useDisclosure(false)
const [selectedError, setSelectedError] = useState(null)
@@ -41,54 +63,62 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
open()
}
- const getSeverityColor = (sev: string) => {
- switch (sev?.toUpperCase()) {
- case 'OPEN': return 'red'
- case 'IN_PROGRESS': return 'orange'
- case 'ON_HOLD': return 'yellow'
- default: return 'gray'
- }
- }
-
return (
<>
-
-
+
+
- LATEST ERROR REPORTS
+
+ Latest Error Reports
+ Most recent open bugs
+
- }>
- View All Reports
-
+
+ }
+ >
+ View All
+
+
-
-
+
+
- Error Message
+ Error Description
Reporter
- App Version
- Timestamp
- Severity
+ Version
+ Reported
+ Status
{isLoading ? (
-
- Loading errors...
+
+
+
+
) : bugs.length === 0 ? (
-
- No errors found.
+
+
+
+ No error reports found.
+
) : bugs.map((error: any) => (
@@ -97,24 +127,34 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
onClick={() => handleRowClick(error)}
style={{ cursor: 'pointer' }}
>
-
+
{error.description}
- {error.user?.name || error.userId || 'System'}
+
+ {error.user?.name || error.userId || 'System'}
+
- {error.affectedVersion || 'N/A'}
+
+ v{error.affectedVersion || 'N/A'}
+
-
+
- {new Date(error.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
+
+ {dayjs(error.createdAt).format('D MMM YYYY, HH:mm')}
+
-
-
- {(error.status || '').toUpperCase()}
+
+
+ {STATUS_LABEL[error.status?.toUpperCase()] ?? error.status}
@@ -131,37 +171,68 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
size="md"
title={
-
- Error Investigation
+
+ Error Detail
}
styles={{
- header: { padding: '24px', borderBottom: '1px solid var(--mantine-color-default-border)' },
+ header: { padding: '20px 24px', borderBottom: '1px solid var(--mantine-color-default-border)' },
}}
>
{selectedError && (
- MESSAGE
- {selectedError.description}
+ Description
+ {selectedError.description}
- SOURCE
- {selectedError.source}
+ Status
+
+ {STATUS_LABEL[selectedError.status?.toUpperCase()] ?? selectedError.status}
+
- APP VERSION
- {selectedError.affectedVersion || 'N/A'}
+ Source
+ {selectedError.source}
+
+
+ App Version
+ v{selectedError.affectedVersion || 'N/A'}
+
+
+ Reported
+ {dayjs(selectedError.createdAt).format('D MMM YYYY, HH:mm')}
+ {selectedError.device && (
+
+ Device
+ {selectedError.device} · {selectedError.os}
+
+ )}
+
+ {selectedError.feedBack && (
+ <>
+
+
+ Developer Feedback
+ {selectedError.feedBack}
+
+ >
+ )}
+
- STACK TRACE
+ Stack Trace
{showStackTrace && (
-
- {selectedError.stackTrace}
+
+ {selectedError.stackTrace || '(no stack trace)'}
)}
@@ -183,5 +258,3 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
>
)
}
-
-import { SimpleGrid, ThemeIcon } from '@mantine/core'
diff --git a/src/frontend/routes/apps.$appId.errors.tsx b/src/frontend/routes/apps.$appId.errors.tsx
index 4f3d173..53bf52f 100644
--- a/src/frontend/routes/apps.$appId.errors.tsx
+++ b/src/frontend/routes/apps.$appId.errors.tsx
@@ -21,12 +21,14 @@ import {
TextInput,
ThemeIcon,
Timeline,
- Title
+ Title,
+ Tooltip,
} 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 dayjs from 'dayjs'
import { useState } from 'react'
import {
TbAlertTriangle,
@@ -39,7 +41,7 @@ import {
TbHistory,
TbPhoto,
TbPlus,
- TbSearch
+ TbSearch,
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
@@ -47,43 +49,48 @@ export const Route = createFileRoute('/apps/$appId/errors')({
component: AppErrorsPage,
})
+const STATUS_COLOR: Record = {
+ OPEN: 'red',
+ IN_PROGRESS: 'blue',
+ ON_HOLD: 'orange',
+ RESOLVED: 'teal',
+ RELEASED: 'green',
+ CLOSED: 'gray',
+}
+const STATUS_LABEL: Record = {
+ OPEN: 'Open',
+ ON_HOLD: 'On Hold',
+ IN_PROGRESS: 'In Progress',
+ RESOLVED: 'Resolved',
+ RELEASED: 'Released',
+ CLOSED: 'Closed',
+}
function AppErrorsPage() {
const { appId } = useParams({ from: '/apps/$appId/errors' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
- const [app, setApp] = useState(appId)
const [status, setStatus] = useState('all')
-
const [showLogs, setShowLogs] = useState>({})
const [showStackTrace, setShowStackTrace] = useState>({})
- const toggleLogs = (bugId: string) => {
- setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
- }
-
- const toggleStackTrace = (bugId: string) => {
- setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
- }
+ 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, search, app, status }],
- queryFn: () =>
- fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
+ queryKey: ['bugs', { page, search, app: appId, status }],
+ queryFn: () => fetch(API_URLS.getBugs(page, search, appId, status)).then((r) => r.json()),
})
- // Fetch apps for the dropdown
const { data: appsList } = useQuery({
queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
- // Image Preview
const [previewImage, setPreviewImage] = useState(null)
- // Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState([])
@@ -97,25 +104,17 @@ function AppErrorsPage() {
stackTrace: '',
})
- // Update Status Modal Logic
const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [selectedBugId, setSelectedBugId] = useState(null)
- const [updateForm, setUpdateForm] = useState({
- status: '',
- description: '',
- })
+ 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 [feedbackForm, setFeedbackForm] = useState({ feedBack: '' })
const handleUpdateFeedback = async () => {
if (!selectedBugId || !feedbackForm.feedBack) return
-
setIsUpdatingFeedback(true)
try {
const res = await fetch(API_URLS.updateBugFeedback(selectedBugId), {
@@ -123,27 +122,16 @@ function AppErrorsPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedbackForm),
})
-
if (res.ok) {
- notifications.show({
- title: 'Success',
- message: 'Feedback has been updated.',
- color: 'teal',
- icon: ,
- })
+ notifications.show({ title: 'Success', message: 'Feedback has been updated.', color: 'teal', icon: })
refetch()
closeFeedbackModal()
setFeedbackForm({ feedBack: '' })
} else {
- throw new Error('Failed to update feedback')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsUpdatingFeedback(false)
}
@@ -151,7 +139,6 @@ function AppErrorsPage() {
const handleUpdateStatus = async () => {
if (!selectedBugId || !updateForm.status) return
-
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateBugStatus(selectedBugId), {
@@ -159,27 +146,16 @@ function AppErrorsPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateForm),
})
-
if (res.ok) {
- notifications.show({
- title: 'Success',
- message: 'Status has been updated.',
- color: 'teal',
- icon: ,
- })
+ notifications.show({ title: 'Success', message: 'Status has been updated.', color: 'teal', icon: })
refetch()
closeUpdateModal()
setUpdateForm({ status: '', description: '' })
} else {
- throw new Error('Failed to update status')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsUpdating(false)
}
@@ -187,14 +163,9 @@ function AppErrorsPage() {
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',
- })
+ notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
return
}
-
setIsSubmitting(true)
try {
const imageUrls: string[] = []
@@ -202,52 +173,31 @@ function AppErrorsPage() {
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('Gagal mengupload gambar')
+ 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: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
+ 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: ,
- })
+ notifications.show({ title: 'Success', message: 'Error report has been created.', color: 'teal', icon: })
refetch()
close()
setImageFiles([])
- setCreateForm({
- description: '',
- app: appId,
- source: 'USER',
- affectedVersion: '',
- device: '',
- os: '',
- stackTrace: '',
- })
+ setCreateForm({ description: '', app: appId, source: 'USER', affectedVersion: '', device: '', os: '', stackTrace: '' })
} else {
- throw new Error('Failed to create error report')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsSubmitting(false)
}
@@ -257,16 +207,19 @@ function AppErrorsPage() {
const totalPages = data?.totalPages || 1
return (
-
-
-
- Error Reporting Center
- Advanced analysis of health issues and crashes for {appId}.
+
+
+
+ Error Reports
+
+ Bug reports and crash tracking for this application.
+
}
+ size="sm"
onClick={open}
>
Report Error
@@ -278,7 +231,7 @@ function AppErrorsPage() {
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
- radius="xl"
+ radius="md"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
@@ -286,12 +239,7 @@ function AppErrorsPage() {
onClick={() => setPreviewImage(null)}
>
{previewImage && (
-
+
)}
@@ -299,28 +247,21 @@ function AppErrorsPage() {
opened={updateModalOpened}
onClose={closeUpdateModal}
title={Update Bug Status}
- radius="xl"
+ radius="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
+
{isLoading ? (
-
- Loading error reports...
+
) : bugs.length === 0 ? (
-
-
- No error reports found
+
+
+ No error reports found
Try adjusting your filters or search terms.
-
+
) : (
{bugs.map((bug: any) => (
@@ -523,19 +461,13 @@ function AppErrorsPage() {
style={{
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-default)',
- marginBottom: '12px',
+ marginBottom: 12,
}}
>
-
- {bug.description}
-
+ {bug.description}
- {bug.status}
+ {STATUS_LABEL[bug.status] ?? bug.status}
-
-
- {new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
-
-
+
+ {dayjs(bug.createdAt).format('D MMM YYYY, HH:mm')} · {bug.appId?.toUpperCase()} · v{bug.affectedVersion}
+
+
- {/* Device Info */}
- DEVICE METADATA
+ Device Metadata
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
@@ -585,17 +507,16 @@ function AppErrorsPage() {
- SOURCE
+ Source
{bug.source}
- {/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
{bug.user && (
- REPORTED BY
+ Reported By
{bug.user.name?.charAt(0).toUpperCase()}
@@ -606,24 +527,18 @@ function AppErrorsPage() {
)}
{bug.feedBack && (
- DEVELOPER FEEDBACK
+ Developer Feedback
{bug.feedBack}
)}
)}
- {/* Stack Trace */}
{bug.stackTrace && (
-
- STACK TRACE
-
)}
- {/* Images */}
{bug.images && bug.images.length > 0 && (
-
- ATTACHED IMAGES ({bug.images.length})
+
+
+ Attached Images ({bug.images.length})
+
{bug.images.map((img: any) => (
- setPreviewImage(img.imageUrl)}
- >
-
-
+
+ setPreviewImage(img.imageUrl)}
+ >
+
+
+
))}
)}
- {/* Logs / History */}
{bug.logs && bug.logs.length > 0 && (
-
- ACTIVITY LOG ({bug.logs.length})
+
+
+ Activity Log ({bug.logs.length})
+
- toggleLogs(bug.id)}
- >
+ toggleLogs(bug.id)}>
{showLogs[bug.id] ? 'Hide' : 'Show'}
@@ -690,12 +598,16 @@ function AppErrorsPage() {
+
+ }
+ title={
+
+ {STATUS_LABEL[log.status] ?? log.status}
+
}
- title={{log.status}}
>
- {new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
+ {dayjs(log.createdAt).format('D MMM YYYY, HH:mm')} · {log.user?.name ?? 'Unknown'}
{log.description}
@@ -706,16 +618,30 @@ function AppErrorsPage() {
)}
- {
- setSelectedBugId(bug.id)
- setFeedbackForm({ feedBack: bug.feedBack || '' })
- openFeedbackModal()
- }}>Developer Feedback
- {
- setSelectedBugId(bug.id)
- setUpdateForm({ status: bug.status, description: '' })
- openUpdateModal()
- }}>Update Status
+ {
+ setSelectedBugId(bug.id)
+ setFeedbackForm({ feedBack: bug.feedBack || '' })
+ openFeedbackModal()
+ }}
+ >
+ Developer Feedback
+
+ {
+ setSelectedBugId(bug.id)
+ setUpdateForm({ status: bug.status, description: '' })
+ openUpdateModal()
+ }}
+ >
+ Update Status
+
@@ -726,7 +652,7 @@ function AppErrorsPage() {
{totalPages > 1 && (
-
+
)}
diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx
index 6e19ea0..609e3ee 100644
--- a/src/frontend/routes/apps.$appId.index.tsx
+++ b/src/frontend/routes/apps.$appId.index.tsx
@@ -4,6 +4,7 @@ import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { useSession } from '@/frontend/hooks/useAuth'
import {
+ ActionIcon,
Badge,
Button,
Group,
@@ -14,7 +15,8 @@ import {
Text,
Textarea,
TextInput,
- Title
+ Title,
+ Tooltip,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@@ -24,7 +26,8 @@ import {
TbActivity,
TbAlertTriangle,
TbBuildingCommunity,
- TbVersions
+ TbRefresh,
+ TbVersions,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -43,14 +46,12 @@ function AppOverviewPage() {
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
- // Form State
const [latestVersion, setLatestVersion] = useState('')
const [minVersion, setMinVersion] = useState('')
const [messageUpdate, setMessageUpdate] = useState('')
const [maintenance, setMaintenance] = useState(false)
const [isSaving, setIsSaving] = useState(false)
- // Data Fetching
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
@@ -64,7 +65,6 @@ function AppOverviewPage() {
const dailyData = dailyRes?.data || []
const comparisonData = comparisonRes?.data || []
- // Initialize form when data loads or modal opens
useEffect(() => {
if (grid?.version && versionModalOpened) {
setLatestVersion(grid.version.mobile_latest_version || '')
@@ -98,37 +98,33 @@ function AppOverviewPage() {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type: 'UPDATE', message: `Update version information: ${JSON.stringify({ latestVersion, minVersion, maintenance, messageUpdate })}` })
+ body: JSON.stringify({ type: 'UPDATE', message: `Updated version info: latest=${latestVersion}, min=${minVersion}, maintenance=${maintenance}` }),
}).catch(console.error)
- notifications.show({
- title: 'Update Successful',
- message: 'Application version information has been updated.',
- color: 'teal',
- })
+ notifications.show({ title: 'Updated', message: 'Application version information has been saved.', color: 'teal' })
mutateGrid()
closeVersionModal()
} else {
- notifications.show({
- title: 'Update Failed',
- message: 'Failed to update version information. Please check your data.',
- color: 'red',
- })
+ notifications.show({ title: 'Failed', message: 'Could not update version info. Please try again.', color: 'red' })
}
- } catch (error) {
- notifications.show({
- title: 'Network Error',
- message: 'Could not connect to the server. Please try again later.',
- color: 'red',
- })
+ } catch {
+ notifications.show({ title: 'Network Error', message: 'Could not connect to the server.', color: 'red' })
} finally {
setIsSaving(false)
}
}
+ const maintenanceOn = grid?.version?.mobile_maintenance === 'true'
+
return (
<>
-
+ Update Version Info}
+ radius="xl"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
setMaintenance(e.currentTarget.checked)}
/>
- Save Changes
+
+ Save Changes
+
-
-
+
+
Overview
- Detailed metrics for {isDesaPlus ? 'Desa+' : appId}
+
+ Real-time metrics and activity for {isDesaPlus ? 'Desa+' : appId}.
+
-
- {/*
-
-
+
+
+
- */}
+
@@ -185,12 +198,12 @@ function AppOverviewPage() {
Min. Version
- {grid?.version?.mobile_minimum_version || '-'}
+ {grid?.version?.mobile_minimum_version || '—'}
Maintenance
-
- {grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
+
+ {maintenanceOn ? 'On' : 'Off'}
@@ -198,35 +211,44 @@ function AppOverviewPage() {
0 } : undefined}
+ trend={grid?.activity?.increase
+ ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 }
+ : undefined}
/>
navigate({ to: `/apps/${appId}/villages` })}
>
- Nonactive Villages
- {grid?.village?.inactive || 0}
+ Inactive
+ {grid?.village?.inactive ?? 0}
navigate({ to: `/apps/${appId}/errors` })}
/>
+
+
+ Analytics
+ Activity trends and village comparisons.
+
+
+
diff --git a/src/frontend/routes/apps.$appId.logs.tsx b/src/frontend/routes/apps.$appId.logs.tsx
index 5a6bd2e..157aa3e 100644
--- a/src/frontend/routes/apps.$appId.logs.tsx
+++ b/src/frontend/routes/apps.$appId.logs.tsx
@@ -1,34 +1,30 @@
import { useState } from 'react'
import useSWR from 'swr'
import {
- Badge,
- Group,
- Stack,
- Text,
- Title,
- Paper,
- Table,
- TextInput,
ActionIcon,
Avatar,
+ Badge,
Code,
- Button,
- Box,
+ Group,
+ Loader,
Pagination,
- ThemeIcon,
+ Paper,
ScrollArea,
- Container,
+ Stack,
+ Table,
+ Text,
+ TextInput,
+ Title,
+ Tooltip,
} from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
- TbSearch,
- TbDownload,
- TbX,
+ TbAlertCircle,
TbHistory,
- TbCalendar,
- TbUser,
- TbHome2
+ TbHome2,
+ TbSearch,
+ TbX,
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
@@ -47,6 +43,18 @@ interface LogEntry {
const fetcher = (url: string) => fetch(url).then((res) => res.json())
+const ACTION_COLOR: Record = {
+ LOGIN: 'teal',
+ LOGOUT: 'gray',
+ CREATE: 'blue',
+ UPDATE: 'yellow',
+ DELETE: 'red',
+}
+
+function getActionColor(action: string) {
+ return ACTION_COLOR[action.toUpperCase()] ?? 'brand-blue'
+}
+
function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' })
const [page, setPage] = useState(1)
@@ -74,162 +82,142 @@ function AppLogsPage() {
setPage(1)
}
- const getActionColor = (action: string) => {
- const a = action.toUpperCase()
- if (a === 'LOGIN') return 'blue'
- if (a === 'LOGOUT') return 'gray'
- if (a === 'CREATE') return 'teal'
- if (a === 'UPDATE') return 'orange'
- if (a === 'DELETE') return 'red'
- return 'brand-blue'
- }
-
if (!isDesaPlus) {
return (
-
-
-
- Activity Logs
- This feature is currently customized for Desa+. Other apps coming soon.
-
-
+
+
+
+ Activity Logs — Coming Soon
+ This feature is currently available for Desa+. Other apps coming soon.
+
+
)
}
return (
-
-
-
-
-
-
-
-
- Activity Logs
-
-
- {isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
-
-
- {/* }
- radius="md"
- size="md"
- >
- Export
- */}
-
-
- }
- size="md"
- rightSection={
- search ? (
-
-
-
- ) : null
- }
- value={search}
- onChange={(e) => handleSearchChange(e.currentTarget.value)}
- radius="md"
- style={{ maxWidth: 500 }}
- ml={40}
- />
+
+
+ Activity Logs
+
+ {isLoading
+ ? 'Loading logs...'
+ : `${(response?.data?.total ?? 0).toLocaleString()} events across all villages`}
+
+
+
+
+ }
+ size="sm"
+ rightSection={
+ search ? (
+
+
+
+
+
+ ) : null
+ }
+ value={search}
+ onChange={(e) => handleSearchChange(e.currentTarget.value)}
+ radius="md"
+ />
{isLoading ? (
-
- Fetching activity logs...
-
+
+
+
) : error ? (
-
- Failed to load logs from API.
+
+
+
+ Failed to load logs from the API.
+
) : logs.length === 0 ? (
-
-
- No activity found for this search.
+
+
+
+
+ {searchQuery ? 'No activity found for this search.' : 'No activity logs yet.'}
+
+
) : (
-
-
+
- Timestamp
- User & Village
- Action
- Description
+ Timestamp
+ User & Village
+ Action
+ Description
{logs.map((log) => (
-
+
-
-
-
-
- {log.createdAt.endsWith('lalu') ? (
- {log.createdAt}
- ) : (
-
-
- {log.createdAt.split(' ').slice(1).join(' ')}
-
-
- {log.createdAt.split(' ')[0]}
-
-
- )}
-
+ {log.createdAt.endsWith('lalu') ? (
+ {log.createdAt}
+ ) : (
+
+
+ {log.createdAt.split(' ').slice(1).join(' ')}
+
+
+ {log.createdAt.split(' ')[0]}
+
+
+ )}
-
+
{log.username.charAt(0)}
- {log.username}
+ {log.username}
-
+
{log.village}
-
{log.action}
-
+
{log.desc}
@@ -242,11 +230,12 @@ function AppLogsPage() {
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
-
+
= {
+ active: 'teal',
+ warning: 'orange',
+ error: 'red',
+}
+const STATUS_LABEL: Record = {
+ active: 'Active',
+ warning: 'Warning',
+ error: 'Error',
+}
+
function AppDetailLayout() {
const { appId } = useParams({ from: '/apps/$appId' })
- const navigate = useNavigate()
- // Format app ID for display (e.g., desa-plus -> Desa+)
- const appName = appId
- .split('-')
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ')
- .replace('Plus', '+')
+ const { data: appData, isLoading } = useQuery({
+ queryKey: ['apps', appId],
+ queryFn: () => fetch(`/api/apps/${appId}`).then((r) => r.json()),
+ staleTime: 30_000,
+ })
+
+ const configName = APP_CONFIGS[appId]?.name
+ const displayName = appData?.name ?? configName ?? appId
+
+ const statusKey = appData?.maintenance ? 'maintenance' : (appData?.status ?? 'active')
+ const statusColor = appData?.maintenance ? 'gray' : (STATUS_COLOR[appData?.status] ?? 'gray')
+ const statusLabel = appData?.maintenance ? 'Maintenance' : (STATUS_LABEL[appData?.status] ?? appData?.status)
return (
-
-
- {appName}
- Application ID: {appId}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ {displayName}
+ )}
+ {!isLoading && appData && (
+
+ {statusLabel}
+
+ )}
+
+
+
+
+ {appId}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {(appData?.errors ?? 0) > 0 && (
+ }
+ >
+ {appData.errors} open {appData.errors === 1 ? 'error' : 'errors'}
+
+ )}
+ {appData?.maintenance && (
+ }
+ >
+ Maintenance mode
+
+ )}
+ >
+ )}
+
diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx
index bfa2232..221034d 100644
--- a/src/frontend/routes/apps.$appId.users.index.tsx
+++ b/src/frontend/routes/apps.$appId.users.index.tsx
@@ -4,9 +4,9 @@ import {
Badge,
Box,
Button,
- Container,
Divider,
Group,
+ Loader,
Modal,
Pagination,
Paper,
@@ -14,18 +14,20 @@ import {
Select,
SimpleGrid,
Stack,
+ Switch,
Table,
Text,
TextInput,
ThemeIcon,
Title,
- Switch,
+ Tooltip,
} from '@mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
+ TbAlertCircle,
TbBriefcase,
TbCircleCheck,
TbCircleX,
@@ -160,9 +162,7 @@ function UsersIndexPage() {
try {
const res = await fetch(API_URLS.createUser(), {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
@@ -172,7 +172,7 @@ function UsersIndexPage() {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type: 'CREATE', message: `Didaftarkan user (${appId}) baru: ${form.name}-${form.nik}` })
+ body: JSON.stringify({ type: 'CREATE', message: `New user registered (${appId}): ${form.name} - ${form.nik}` })
}).catch(console.error)
notifications.show({
@@ -181,19 +181,9 @@ function UsersIndexPage() {
color: 'teal',
icon:
})
- mutate() // Refresh user list
+ mutate()
close()
- setForm({
- name: '',
- nik: '',
- phone: '',
- email: '',
- gender: '',
- idUserRole: '',
- idVillage: '',
- idGroup: '',
- idPosition: ''
- })
+ setForm({ name: '', nik: '', phone: '', email: '', gender: '', idUserRole: '', idVillage: '', idGroup: '', idPosition: '' })
} else {
notifications.show({
title: 'Error',
@@ -202,12 +192,8 @@ function UsersIndexPage() {
icon:
})
}
- } catch (e) {
- notifications.show({
- title: 'Network Error',
- message: 'Unable to connect to the server.',
- color: 'red'
- })
+ } catch {
+ notifications.show({ title: 'Network Error', message: 'Unable to connect to the server.', color: 'red' })
} finally {
setIsSubmitting(false)
}
@@ -249,9 +235,7 @@ function UsersIndexPage() {
try {
const res = await fetch(API_URLS.editUser(), {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
@@ -261,7 +245,7 @@ function UsersIndexPage() {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type: 'UPDATE', message: `Data user (${appId}) diperbarui: ${editForm.name}-${editForm.id}` })
+ body: JSON.stringify({ type: 'UPDATE', message: `User updated (${appId}): ${editForm.name} - ${editForm.id}` })
}).catch(console.error)
notifications.show({
@@ -280,12 +264,8 @@ function UsersIndexPage() {
icon:
})
}
- } catch (e) {
- notifications.show({
- title: 'Network Error',
- message: 'Unable to connect to the server.',
- color: 'red'
- })
+ } catch {
+ notifications.show({ title: 'Network Error', message: 'Unable to connect to the server.', color: 'red' })
} finally {
setIsSubmitting(false)
}
@@ -309,348 +289,342 @@ function UsersIndexPage() {
if (!isDesaPlus) {
return (
-
-
-
- User Management
- This feature is currently customized for Desa+. Other apps coming soon.
-
-
+
+
+
+ User Management
+ This feature is currently available for Desa+. Other apps coming soon.
+
+
)
}
return (
-
-
-
-
-
-
-
-
- User Management
-
-
- {isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
-
-
- }
- radius="md"
- size="md"
- onClick={open}
- >
- Add User
-
-
+ {/* Add User Modal */}
+ Add New User}
+ radius="md"
+ size="lg"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+
+
+ Personal Information
+
+
+ setForm(f => ({ ...f, name: e.target.value }))}
+ />
+ setForm(f => ({ ...f, nik: e.target.value }))}
+ />
+
- Add New User}
- radius="xl"
- size="lg"
- overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
- >
-
-
-
- Personal Information
-
-
- setForm(f => ({ ...f, name: e.target.value }))}
- />
- setForm(f => ({ ...f, nik: e.target.value }))}
- />
-
+
+ setForm(f => ({ ...f, email: e.target.value }))}
+ />
+ setForm(f => ({ ...f, phone: e.target.value }))}
+ />
+
-
- setForm(f => ({ ...f, email: e.target.value }))}
- />
- setForm(f => ({ ...f, phone: e.target.value }))}
- />
-
+
-
+
-
+
+
-
-
- Register User
-
-
-
-
- Edit User}
- radius="xl"
- size="lg"
- overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
- >
-
-
-
- Personal Information
-
-
- setEditForm(f => ({ ...f, name: e.target.value }))}
- />
- setEditForm(f => ({ ...f, nik: e.target.value }))}
- />
-
-
-
- setEditForm(f => ({ ...f, email: e.target.value }))}
- />
- setEditForm(f => ({ ...f, phone: e.target.value }))}
- />
-
-
- setEditForm(f => ({ ...f, gender: v || '' }))}
- />
-
-
-
-
-
- setEditForm(f => ({ ...f, idUserRole: v || '' }))}
- />
-
- {
- setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
- }}
- />
-
-
- {
- setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
- }}
- />
- setEditForm(f => ({ ...f, idPosition: v || '' }))}
- />
-
-
-
-
-
-
- setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
- />
- setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
- />
-
-
-
- Update User
-
-
-
-
- }
- size="md"
- rightSection={
- search ? (
-
-
-
- ) : null
- }
- value={search}
- onChange={(e) => handleSearchChange(e.currentTarget.value)}
+
+ variant="gradient"
+ gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
+ loading={isSubmitting}
+ onClick={handleCreateUser}
+ >
+ Register User
+
+
+
+ {/* Edit User Modal */}
+ Edit User}
+ radius="md"
+ size="lg"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+
+
+ Personal Information
+
+
+ setEditForm(f => ({ ...f, name: e.target.value }))}
+ />
+ setEditForm(f => ({ ...f, nik: e.target.value }))}
+ />
+
+
+
+ setEditForm(f => ({ ...f, email: e.target.value }))}
+ />
+ setEditForm(f => ({ ...f, phone: e.target.value }))}
+ />
+
+
+ setEditForm(f => ({ ...f, gender: v || '' }))}
+ />
+
+
+
+
+
+ setEditForm(f => ({ ...f, idUserRole: v || '' }))}
+ />
+
+ setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))}
+ />
+
+
+ setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))}
+ />
+ setEditForm(f => ({ ...f, idPosition: v || '' }))}
+ />
+
+
+
+
+
+
+ setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
+ />
+ setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
+ />
+
+
+
+ Update User
+
+
+
+
+ {/* Header */}
+
+
+ User Management
+
+ {isLoading ? 'Loading users...' : `${response?.data?.total ?? 0} users registered in the Desa+ system`}
+
+
+ }
+ size="sm"
+ onClick={open}
+ >
+ Add User
+
+
+
+ {/* Search / Filter */}
+
+ }
+ size="sm"
+ rightSection={
+ search ? (
+
+
+
+
+
+ ) : null
+ }
+ value={search}
+ onChange={(e) => handleSearchChange(e.currentTarget.value)}
+ radius="md"
+ />
{isLoading ? (
-
- Loading user data...
-
+
+
+
) : error ? (
-
- Failed to load data from API.
+
+
+
+ Failed to load users from the API.
+
) : users.length === 0 ? (
-
-
- No users match your criteria.
+
+
+
+
+ {searchQuery ? 'No users match your search.' : 'No users found.'}
+
+
) : (
-
+
- User & ID
- Contact Detail
- Organization
- Role
- Status
+ User & ID
+ Contact
+ Organization
+ Role
+ Status
{users.map((user) => (
- {handleEditOpen(user)}}>
+ handleEditOpen(user)}
+ >
{user.name.charAt(0)}
- {user.name}
+ {user.name}
- {user.nik}
+ {user.nik}
@@ -727,11 +705,10 @@ function UsersIndexPage() {
{user.role}
@@ -740,11 +717,15 @@ function UsersIndexPage() {
- {user.isActive ? (
-
- ) : (
-
- )}
+
{user.isActive ? 'ACTIVE' : 'INACTIVE'}
@@ -765,11 +746,12 @@ function UsersIndexPage() {
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
-
+
- Loading chart data...
+
) : (
- Loading village data...
-
+
+
+
)
}
@@ -321,7 +322,7 @@ function VillageDetailPage() {
loading={isUpdating}
disabled={!isDeveloper}
>
- {village.isActive ? 'Deactivate' : 'Active'}
+ {village.isActive ? 'Deactivate' : 'Activate'}
Confirm Status Change}
- radius="xl"
+ radius="md"
+ title={Confirm Status Change}
centered
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
@@ -505,7 +507,7 @@ function VillageDetailPage() {
opened={editModalOpened}
onClose={closeEditModal}
title={Edit Village Details}
- radius="xl"
+ radius="md"
size="md"
>
diff --git a/src/frontend/routes/bug-reports.tsx b/src/frontend/routes/bug-reports.tsx
index fb02f54..7c0c10a 100644
--- a/src/frontend/routes/bug-reports.tsx
+++ b/src/frontend/routes/bug-reports.tsx
@@ -9,6 +9,7 @@ import {
Code,
Collapse,
Container,
+ FileInput,
Group,
Image,
Loader,
@@ -19,36 +20,54 @@ import {
SimpleGrid,
Stack,
Text,
- ThemeIcon,
- FileInput,
TextInput,
Textarea,
- Title,
+ ThemeIcon,
Timeline,
+ Title,
+ Tooltip,
} 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 { useQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+import dayjs from 'dayjs'
+import { useState } from 'react'
import {
TbAlertTriangle,
TbBug,
+ TbCircleCheck,
+ TbCircleX,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
- TbSearch,
TbHistory,
TbPhoto,
TbPlus,
- TbCircleCheck,
- TbCircleX,
+ TbSearch,
} from 'react-icons/tb'
export const Route = createFileRoute('/bug-reports')({
component: ListErrorsPage,
})
+const STATUS_COLOR: Record = {
+ OPEN: 'red',
+ IN_PROGRESS: 'blue',
+ ON_HOLD: 'orange',
+ RESOLVED: 'teal',
+ RELEASED: 'green',
+ CLOSED: 'gray',
+}
+const STATUS_LABEL: Record = {
+ 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('')
@@ -58,29 +77,21 @@ function ListErrorsPage() {
const [showLogs, setShowLogs] = useState>({})
const [showStackTrace, setShowStackTrace] = useState>({})
- const toggleLogs = (bugId: string) => {
- setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
- }
- const toggleStackTrace = (bugId: string) => {
- setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
- }
+ 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, search, app, status }],
- queryFn: () =>
- fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
+ queryFn: () => fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
})
- // Fetch apps for the dropdown
const { data: appsList } = useQuery({
queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
- // Image Preview
const [previewImage, setPreviewImage] = useState(null)
- // Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState([])
@@ -94,25 +105,17 @@ function ListErrorsPage() {
stackTrace: '',
})
- // Update Status Modal Logic
const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [selectedBugId, setSelectedBugId] = useState(null)
- const [updateForm, setUpdateForm] = useState({
- status: '',
- description: '',
- })
+ 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 [feedbackForm, setFeedbackForm] = useState({ feedBack: '' })
const handleUpdateFeedback = async () => {
if (!selectedBugId || !feedbackForm.feedBack) return
-
setIsUpdatingFeedback(true)
try {
const res = await fetch(API_URLS.updateBugFeedback(selectedBugId), {
@@ -120,27 +123,16 @@ function ListErrorsPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedbackForm),
})
-
if (res.ok) {
- notifications.show({
- title: 'Success',
- message: 'Feedback has been updated.',
- color: 'teal',
- icon: ,
- })
+ notifications.show({ title: 'Success', message: 'Feedback has been updated.', color: 'teal', icon: })
refetch()
closeFeedbackModal()
setFeedbackForm({ feedBack: '' })
} else {
- throw new Error('Failed to update feedback')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsUpdatingFeedback(false)
}
@@ -148,7 +140,6 @@ function ListErrorsPage() {
const handleUpdateStatus = async () => {
if (!selectedBugId || !updateForm.status) return
-
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateBugStatus(selectedBugId), {
@@ -156,27 +147,16 @@ function ListErrorsPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateForm),
})
-
if (res.ok) {
- notifications.show({
- title: 'Success',
- message: 'Status has been updated.',
- color: 'teal',
- icon: ,
- })
+ notifications.show({ title: 'Success', message: 'Status has been updated.', color: 'teal', icon: })
refetch()
closeUpdateModal()
setUpdateForm({ status: '', description: '' })
} else {
- throw new Error('Failed to update status')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsUpdating(false)
}
@@ -184,14 +164,9 @@ function ListErrorsPage() {
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',
- })
+ notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
return
}
-
setIsSubmitting(true)
try {
const imageUrls: string[] = []
@@ -199,52 +174,31 @@ function ListErrorsPage() {
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('Gagal mengupload gambar')
+ 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: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
+ 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: ,
- })
+ notifications.show({ title: 'Success', message: 'Error report has been created.', color: 'teal', icon: })
refetch()
close()
setImageFiles([])
- setCreateForm({
- description: '',
- app: 'desa-plus',
- source: 'USER',
- affectedVersion: '',
- device: '',
- os: '',
- stackTrace: '',
- })
+ setCreateForm({ description: '', app: 'desa-plus', source: 'USER', affectedVersion: '', device: '', os: '', stackTrace: '' })
} else {
- throw new Error('Failed to create error report')
+ throw new Error()
}
- } catch (e) {
- notifications.show({
- title: 'Error',
- message: 'Something went wrong.',
- color: 'red',
- icon: ,
- })
+ } catch {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsSubmitting(false)
}
@@ -257,28 +211,22 @@ function ListErrorsPage() {
-
-
-
- Error Reports
-
+
+
+ Error Reports
Centralized error tracking and analysis for all applications.
-
- }
- onClick={open}
- >
- Report Error
-
- {/* }>
- Generate Report
- */}
-
+ }
+ size="sm"
+ onClick={open}
+ >
+ Report Error
+
{/* Image Preview Modal */}
@@ -286,7 +234,7 @@ function ListErrorsPage() {
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
- radius="xl"
+ radius="md"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
@@ -294,12 +242,7 @@ function ListErrorsPage() {
onClick={() => setPreviewImage(null)}
>
{previewImage && (
-
+
)}
@@ -307,28 +250,21 @@ function ListErrorsPage() {
opened={updateModalOpened}
onClose={closeUpdateModal}
title={Update Bug Status}
- radius="xl"
+ radius="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
({ value, label }))}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
) : (
{bugs.map((bug: any) => (
@@ -542,19 +475,13 @@ function ListErrorsPage() {
style={{
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-default)',
- marginBottom: '12px',
+ marginBottom: 12,
}}
>
-
- {bug.description}
-
+ {bug.description}
- {bug.status}
+ {STATUS_LABEL[bug.status] ?? bug.status}
-
-
- {new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
-
-
+
+ {dayjs(bug.createdAt).format('D MMM YYYY, HH:mm')} · {bug.appId?.toUpperCase()} · v{bug.affectedVersion}
+
+
- {/* Device Info */}
- DEVICE METADATA
+ Device Metadata
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
@@ -604,17 +521,16 @@ function ListErrorsPage() {
- SOURCE
+ Source
{bug.source}
- {/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
{bug.user && (
- REPORTED BY
+ Reported By
{bug.user.name?.charAt(0).toUpperCase()}
@@ -625,24 +541,18 @@ function ListErrorsPage() {
)}
{bug.feedBack && (
- DEVELOPER FEEDBACK
+ Developer Feedback
{bug.feedBack}
)}
)}
- {/* Stack Trace */}
{bug.stackTrace && (
- STACK TRACE
- toggleStackTrace(bug.id)}
- >
+ Stack Trace
+ toggleStackTrace(bug.id)}>
{showStackTrace[bug.id] ? 'Hide' : 'Show'}
@@ -650,12 +560,7 @@ function ListErrorsPage() {
{bug.stackTrace}
@@ -663,43 +568,41 @@ function ListErrorsPage() {
)}
- {/* Images */}
{bug.images && bug.images.length > 0 && (
-
- ATTACHED IMAGES ({bug.images.length})
+
+
+ Attached Images ({bug.images.length})
+
{bug.images.map((img: any) => (
- setPreviewImage(img.imageUrl)}
- >
-
-
+
+ setPreviewImage(img.imageUrl)}
+ >
+
+
+
))}
)}
- {/* Logs / History */}
{bug.logs && bug.logs.length > 0 && (
-
- ACTIVITY LOG ({bug.logs.length})
+
+
+ Activity Log ({bug.logs.length})
+
- toggleLogs(bug.id)}
- >
+ toggleLogs(bug.id)}>
{showLogs[bug.id] ? 'Hide' : 'Show'}
@@ -709,12 +612,16 @@ function ListErrorsPage() {
+
+ }
+ title={
+
+ {STATUS_LABEL[log.status] ?? log.status}
+
}
- title={{log.status}}
>
- {new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
+ {dayjs(log.createdAt).format('D MMM YYYY, HH:mm')} · {log.user?.name ?? 'Unknown'}
{log.description}
@@ -725,16 +632,30 @@ function ListErrorsPage() {
)}
- {
- setSelectedBugId(bug.id)
- setFeedbackForm({ feedBack: bug.feedBack || '' })
- openFeedbackModal()
- }}>Developer Feedback
- {
- setSelectedBugId(bug.id)
- setUpdateForm({ status: bug.status, description: '' })
- openUpdateModal()
- }}>Update Status
+ {
+ setSelectedBugId(bug.id)
+ setFeedbackForm({ feedBack: bug.feedBack || '' })
+ openFeedbackModal()
+ }}
+ >
+ Developer Feedback
+
+ {
+ setSelectedBugId(bug.id)
+ setUpdateForm({ status: bug.status, description: '' })
+ openUpdateModal()
+ }}
+ >
+ Update Status
+
@@ -745,7 +666,7 @@ function ListErrorsPage() {
{totalPages > 1 && (
-
+
)}
diff --git a/src/frontend/routes/dashboard.tsx b/src/frontend/routes/dashboard.tsx
index 8612f94..0a8d8a5 100644
--- a/src/frontend/routes/dashboard.tsx
+++ b/src/frontend/routes/dashboard.tsx
@@ -14,10 +14,11 @@ import {
Table,
Text,
Title,
+ Tooltip,
} from '@mantine/core'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
-import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
+import { TbAlertCircle, TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
@@ -35,6 +36,39 @@ export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
})
+function getGreeting() {
+ const hour = new Date().getHours()
+ if (hour < 12) return 'Good morning'
+ if (hour < 17) return 'Good afternoon'
+ return 'Good evening'
+}
+
+function formatTimeAgo(dateStr: string) {
+ const diff = new Date().getTime() - new Date(dateStr).getTime()
+ const minutes = Math.floor(diff / 60000)
+ if (minutes < 1) return 'Just now'
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ if (days === 1) return 'Yesterday'
+ if (days < 7) return `${days}d ago`
+ return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
+}
+
+const SEVERITY_COLOR: Record = {
+ OPEN: 'red',
+ IN_PROGRESS: 'blue',
+ ON_HOLD: 'orange',
+ RESOLVED: 'teal',
+ RELEASED: 'green',
+ CLOSED: 'gray',
+}
+
+function formatSeverityLabel(s: string) {
+ return s.replace(/_/g, ' ')
+}
+
function DashboardPage() {
const { data: sessionData } = useSession()
const user = sessionData?.user
@@ -54,34 +88,42 @@ function DashboardPage() {
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`
- }
+ const today = new Date().toLocaleDateString('en-GB', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ })
+
+ const firstName = user?.name?.split(' ')[0] ?? user?.name
return (
-
-
- Overview Dashboard
- Welcome back, {user?.name}. Here is what's happening today.
+
+
+
+ {today}
+
+
+ {getGreeting()}, {firstName}.
+
+
+ Here's a real-time overview of all your monitored applications.
+
- {/* }
radius="md"
component={Link}
to="/apps"
+ size="sm"
>
- Manage All Apps
- */}
+ Manage Apps
+
{statsLoading ? (
@@ -89,33 +131,43 @@ function DashboardPage() {
) : (
)}
-
- Registered Applications
- } component={Link} to="/apps">
- View All Apps
+
+
+ Registered Applications
+ All monitored apps on this platform.
+
+ }
+ component={Link}
+ to="/apps"
+ size="sm"
+ >
+ View All
@@ -129,22 +181,32 @@ function DashboardPage() {
)}
-
- Recent Error Reports
- } component={Link} to="/bug-reports">
- View All Errors
+
+
+ Recent Error Reports
+ Latest bug submissions across all apps.
+
+ }
+ component={Link}
+ to="/bug-reports"
+ size="sm"
+ >
+ View All
-
+
- Application
+ App
Error Message
Version
- Time
- Severity
+ Reported
+ Status
@@ -156,30 +218,39 @@ function DashboardPage() {
) : recentErrors.length === 0 ? (
-
- No recent errors found.
+
+
+
+ No error reports yet — all systems are running smoothly.
+
) : recentErrors.map((error: any) => (
- {error.app}
+ {error.app}
+
+
+
+
+ {error.message}
+
+
- {error.message}
-
-
- v{error.version}
+ v{error.version}
{formatTimeAgo(error.time)}
- {error.severity.toUpperCase()}
+ {formatSeverityLabel(error.severity)}
diff --git a/src/frontend/routes/logs.tsx b/src/frontend/routes/logs.tsx
index d5075aa..6508011 100644
--- a/src/frontend/routes/logs.tsx
+++ b/src/frontend/routes/logs.tsx
@@ -1,24 +1,25 @@
import {
ActionIcon,
Badge,
- Center,
Container,
Group,
Loader,
Pagination,
+ Paper,
SegmentedControl,
Select,
Stack,
Table,
Text,
Title,
+ Tooltip,
} from '@mantine/core'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import dayjs from 'dayjs'
import 'dayjs/locale/id'
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
-import { TbRefresh } from 'react-icons/tb'
+import { TbHistory, TbRefresh } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -30,8 +31,16 @@ export const Route = createFileRoute('/logs')({
const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then((r) => r.json())
const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const
+const LOG_TYPE_LABEL: Record = {
+ all: 'All',
+ LOGIN: 'Login',
+ LOGOUT: 'Logout',
+ CREATE: 'Create',
+ UPDATE: 'Update',
+ DELETE: 'Delete',
+}
const LOG_TYPE_COLOR: Record = {
- LOGIN: 'green',
+ LOGIN: 'teal',
LOGOUT: 'gray',
CREATE: 'blue',
UPDATE: 'yellow',
@@ -47,9 +56,9 @@ function GlobalLogsPage() {
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
const operatorOptions = useMemo(() => {
- if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'Semua operator' }]
+ if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All users' }]
return [
- { value: 'all', label: 'Semua user' },
+ { value: 'all', label: 'All users' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name })),
]
}, [operatorsData])
@@ -69,88 +78,149 @@ function GlobalLogsPage() {
return (
-
-
- Activity Logs
- mutate()}>
-
-
+
+
+
+ Activity Logs
+
+ Track all user actions and system events across the platform.
+
+
+
+ mutate()}
+ loading={isLoading}
+ >
+
+
+
-
- { setOperatorId(v ?? 'all'); setPage(1) }}
- data={operatorOptions}
- w={180}
- clearable
- />
- { setDateRange(v); setPage(1) }}
- locale="id"
- valueFormat="DD MMM YYYY"
- clearable
- w={300}
- />
- { setType(v); setPage(1) }}
- data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
- />
-
+
+
+ { setOperatorId(v ?? 'all'); setPage(1) }}
+ data={operatorOptions}
+ w={200}
+ clearable
+ size="sm"
+ />
+ { setDateRange(v); setPage(1) }}
+ locale="id"
+ valueFormat="DD MMM YYYY"
+ clearable
+ w={280}
+ size="sm"
+ />
+
+ Action type
+ { setType(v); setPage(1) }}
+ size="sm"
+ data={LOG_TYPES.map((t) => ({ label: LOG_TYPE_LABEL[t] ?? t, value: t }))}
+ />
+
+
+
- {isLoading ? (
-
+ {isLoading && !data ? (
+
+
+
) : (
- <>
+
-
+
-
-
-
+
+
+
- Time
- Operator
- Type
- Message
+ Timestamp
+ User
+ Action
+ Description
{logs.map((log: any) => (
- {new Date(log.createdAt).toLocaleString('id-ID')}
+
+
+ {dayjs(log.createdAt).locale('id').format('D MMM YYYY')}
+
+
+ {dayjs(log.createdAt).format('HH:mm:ss')}
+
+
{log.user ? (
-
- {log.user.name}
- {log.user.email}
-
- ) : —}
+
+ {log.user.name}
+ {log.user.email}
+
+ ) : (
+ —
+ )}
-
- {log.type}
+
+ {LOG_TYPE_LABEL[log.type] ?? log.type}
- {log.message}
+
+
+ {log.message}
+
+
))}
{logs.length === 0 && (
- Belum ada log aktivitas
+
+
+
+ No activity logs found for the selected filters.
+
+
)}
@@ -158,11 +228,11 @@ function GlobalLogsPage() {
{totalPages > 1 && (
-
+
-
+
)}
- >
+
)}
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx
index a87e060..2134067 100644
--- a/src/frontend/routes/users.tsx
+++ b/src/frontend/routes/users.tsx
@@ -11,6 +11,7 @@ import {
Divider,
Group,
List,
+ Loader,
Modal,
Pagination,
Paper,
@@ -41,6 +42,7 @@ import {
TbTrash,
TbUserCheck,
TbUserPlus,
+ TbUsers,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -52,45 +54,50 @@ export const Route = createFileRoute('/users')({
const fetcher = (url: string) => fetch(url).then((res) => res.json())
-const getRoleColor = (role: string) => {
- if (role === 'DEVELOPER') return 'violet'
- if (role === 'ADMIN') return 'brand-blue'
- return 'gray'
+const ROLE_COLOR: Record = {
+ DEVELOPER: 'violet',
+ ADMIN: 'brand-blue',
+ USER: 'gray',
+}
+const ROLE_LABEL: Record = {
+ DEVELOPER: 'Developer',
+ ADMIN: 'Admin',
+ USER: 'User',
}
const roles = [
{
name: 'DEVELOPER',
color: 'violet',
- description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.',
+ description: 'Super admin with full system access, including the Dev Console.',
permissions: [
- 'Akses Dev Console (/dev)',
- 'Manajemen user & role',
- 'Kelola bug report & feedback',
- 'Lihat semua app & log aktivitas',
- 'Kelola versi & status aplikasi',
- 'Hapus log sistem',
+ 'Access Dev Console (/dev)',
+ 'User & role management',
+ 'Manage bug reports & feedback',
+ 'View all apps & activity logs',
+ 'Manage app versions & status',
+ 'Delete system logs',
],
},
{
name: 'ADMIN',
color: 'blue',
- description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.',
+ description: 'Operator who can manage applications, bugs, and view activity logs.',
permissions: [
- 'Lihat & kelola semua aplikasi',
- 'Kelola bug report',
- 'Lihat log aktivitas',
- 'Lihat data user, desa, orders',
- 'Update status village & produk',
+ 'View & manage all applications',
+ 'Manage bug reports',
+ 'View activity logs',
+ 'View user, village, and order data',
+ 'Update village & product status',
],
},
{
name: 'USER',
color: 'gray',
- description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.',
+ description: 'New account pending approval. Awaiting review by an Admin or Developer.',
permissions: [
- 'Akses halaman profil',
- 'Lihat status persetujuan akun',
+ 'Access profile page',
+ 'View account approval status',
],
},
]
@@ -110,7 +117,7 @@ function UsersPage() {
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
API_URLS.getOperators(page, debouncedSearch),
- fetcher
+ fetcher,
)
const operators = response?.data || []
@@ -118,19 +125,13 @@ function UsersPage() {
// ── Create User Modal ──
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
const [isCreating, setIsCreating] = useState(false)
- const [createForm, setCreateForm] = useState({
- name: '',
- email: '',
- password: '',
- role: 'ADMIN',
- })
+ const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', role: 'ADMIN' })
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(), {
@@ -138,7 +139,6 @@ function UsersPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
})
-
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: })
mutateOperators()
@@ -160,11 +160,7 @@ function UsersPage() {
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [isEditing, setIsEditing] = useState(false)
const [editingUserId, setEditingUserId] = useState(null)
- const [editForm, setEditForm] = useState({
- name: '',
- email: '',
- role: '',
- })
+ const [editForm, setEditForm] = useState({ name: '', email: '', role: '' })
const handleOpenEdit = (user: any) => {
setEditingUserId(user.id)
@@ -174,7 +170,6 @@ function UsersPage() {
const handleEditUser = async () => {
if (!editingUserId || !editForm.name || !editForm.email) return
-
setIsEditing(true)
try {
const res = await fetch(API_URLS.editOperator(editingUserId), {
@@ -182,7 +177,6 @@ function UsersPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
-
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: })
mutateOperators()
@@ -190,14 +184,14 @@ function UsersPage() {
} else {
throw new Error('Failed to update user')
}
- } catch (e) {
+ } catch {
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
} finally {
setIsEditing(false)
}
}
- // ── Delete User ──
+ // ── Delete User Modal ──
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deletingUser, setDeletingUser] = useState(null)
@@ -209,13 +203,9 @@ function UsersPage() {
const handleDeleteUser = async () => {
if (!deletingUser) return
-
setIsDeleting(true)
try {
- const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
- method: 'DELETE',
- })
-
+ 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: })
mutateOperators()
@@ -242,7 +232,7 @@ function UsersPage() {
body: JSON.stringify({ active: true }),
})
if (res.ok) {
- notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: })
+ notifications.show({ title: 'Success', message: `${user.name} has been reactivated.`, color: 'teal', icon: })
mutateOperators()
mutateStats()
} else {
@@ -258,39 +248,52 @@ function UsersPage() {
-
-
- Users
- Manage system users, security roles, and application access control.
-
-
+
+ User Management
+ Manage platform users, security roles, and access control.
+
-
-
-
+
+
+
}>User Management
- }>Role Management
+ }>Role Reference
}
radius="md"
- w={350}
+ w={320}
variant="filled"
value={search}
- onChange={(e) => {
- setSearch(e.currentTarget.value)
- setPage(1)
- }}
+ onChange={(e) => { setSearch(e.currentTarget.value); setPage(1) }}
/>
{isDeveloper && (
}
radius="md"
+ size="sm"
onClick={openCreate}
>
Add New User
@@ -311,21 +315,26 @@ function UsersPage() {
Name & Contact
Role
- Joined Date
+ Joined
Actions
{isLoading ? (
-
- Loading user data...
+
+
+
+
) : operators.length === 0 ? (
-
- No users found.
+
+
+
+ No users found.
+
) : (
@@ -334,7 +343,12 @@ function UsersPage() {
-
+
{user.name.charAt(0)}
{user.active === false && (
@@ -350,7 +364,9 @@ function UsersPage() {
- {user.name}
+
+ {user.name}
+
{user.active === false && (
Inactive
)}
@@ -360,31 +376,61 @@ function UsersPage() {
-
- {user.role}
+
+ {ROLE_LABEL[user.role] ?? user.role}
- {new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
+ {new Date(user.createdAt).toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })}
{user.active === false ? (
-
- handleActivateUser(user)}>
+
+ handleActivateUser(user)}
+ >
) : (
<>
- handleOpenEdit(user)}>
-
-
- handleOpenDelete(user)}>
-
-
+
+ handleOpenEdit(user)}
+ >
+
+
+
+
+ handleOpenDelete(user)}
+ >
+
+
+
>
)}
@@ -398,12 +444,7 @@ function UsersPage() {
{response?.totalPages > 1 && (
-
+
)}
@@ -414,20 +455,18 @@ function UsersPage() {
{roles.map((role) => (
-
-
-
-
-
+
+
+
- {role.name}
+ {ROLE_LABEL[role.name] ?? role.name}
{role.description}
- Key Permissions
+ Key Permissions
{p}
))}
-
- {/*
- Edit Permissions
- */}
))}
@@ -460,7 +495,7 @@ function UsersPage() {
opened={createOpened}
onClose={closeCreate}
title={Add New User}
- radius="xl"
+ radius="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
@@ -492,7 +527,7 @@ function UsersPage() {
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={createForm.role}
- onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
+ onChange={(val) => setCreateForm({ ...createForm, role: val || 'ADMIN' })}
/>
Edit User}
- radius="xl"
+ radius="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
@@ -558,21 +593,19 @@ function UsersPage() {
opened={deleteOpened}
onClose={closeDelete}
title={Delete User}
- radius="xl"
+ radius="md"
size="sm"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
- Are you sure you want to delete {deletingUser?.name}? This action cannot be undone.
+ Are you sure you want to delete{' '}
+ {deletingUser?.name}?
+ This action cannot be undone.
-
- Cancel
-
-
- Delete User
-
+ Cancel
+ Delete User
diff --git a/src/logo.svg b/src/logo.svg
index 7ef1500..1c58a22 100644
--- a/src/logo.svg
+++ b/src/logo.svg
@@ -1 +1,17 @@
-
\ No newline at end of file
+