Merge pull request 'amalia/13-apr-26' (#7) from amalia/13-apr-26 into main

Reviewed-on: #7
This commit is contained in:
2026-04-13 17:19:36 +08:00
18 changed files with 1374 additions and 286 deletions

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- Changed the type of `type` on the `log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "LogType" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT');
-- AlterTable
ALTER TABLE "log" DROP COLUMN "type",
ADD COLUMN "type" "LogType" NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "image" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- The `app` column on the `bug` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "bug" DROP COLUMN "app",
ADD COLUMN "app" TEXT;
-- DropEnum
DROP TYPE "App";

View File

@@ -15,10 +15,6 @@ enum Role {
DEVELOPER DEVELOPER
} }
enum App{
desa_plus
hipmi
}
enum BugSource{ enum BugSource{
QC QC
@@ -35,6 +31,14 @@ enum BugStatus{
CLOSED CLOSED
} }
enum LogType{
CREATE
UPDATE
DELETE
LOGIN
LOGOUT
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
@@ -42,6 +46,7 @@ model User {
password String password String
role Role @default(USER) role Role @default(USER)
active Boolean @default(true) active Boolean @default(true)
image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -69,7 +74,7 @@ model Session {
model Log { model Log {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
type String type LogType
message String message String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -81,7 +86,7 @@ model Log {
model Bug { model Bug {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? userId String?
app App app String?
affectedVersion String affectedVersion String
device String device String
os String os String
@@ -116,13 +121,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")
} }

View File

@@ -3,6 +3,7 @@ import { html } from '@elysiajs/html'
import { Elysia } from 'elysia' import { Elysia } from 'elysia'
import { prisma } from './lib/db' import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger'
export function createApp() { export function createApp() {
return new Elysia() return new Elysia()
@@ -43,13 +44,20 @@ export function createApp() {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
}) })
.post('/api/auth/logout', async ({ request, set }) => { .post('/api/auth/logout', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) await prisma.session.deleteMany({ where: { token } }) if (token) {
const sessionObj = await prisma.session.findUnique({ where: { token } })
if (sessionObj) {
await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully')
await prisma.session.deleteMany({ where: { token } })
}
}
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
return { ok: true } return { ok: true }
}) })
@@ -139,6 +147,8 @@ export function createApp() {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
await createSystemLog(user.id, 'LOGIN', 'Logged in via Google')
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
}) })
@@ -153,8 +163,8 @@ export function createApp() {
.get('/api/apps', () => [ .get('/api/apps', () => [
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
{ id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, // { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
{ id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' }, // { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
]) ])
.get('/api/apps/:appId', ({ params: { appId } }) => { .get('/api/apps/:appId', ({ params: { appId } }) => {
@@ -164,6 +174,214 @@ export function createApp() {
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' } return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
}) })
.get('/api/logs', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = (query.search as string) || ''
const type = query.type as any
const userId = query.userId as string
const where: any = {}
if (search) {
where.OR = [
{ message: { contains: search, mode: 'insensitive' } },
{ user: { name: { contains: search, mode: 'insensitive' } } }
]
}
if (type && type !== 'all') {
where.type = type
}
if (userId && userId !== 'all') {
where.userId = userId
}
const [logs, total] = await Promise.all([
prisma.log.findMany({
where,
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.log.count({ where })
])
return {
data: logs,
totalPages: Math.ceil(total / limit),
totalItems: total
}
})
.post('/api/logs', 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 { type: string, message: string }
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || ''
await createSystemLog(actingUserId, body.type as any, body.message)
return { ok: true }
})
.get('/api/operators', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = (query.search as string) || ''
const where: any = {}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }
]
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
orderBy: { name: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where })
])
return {
data: users,
totalPages: Math.ceil(total / limit),
totalItems: total
}
})
.get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
prisma.user.count(),
prisma.session.count({
where: { expiresAt: { gte: new Date() } },
}),
prisma.user.groupBy({
by: ['role'],
_count: true
})
])
return {
totalStaff,
activeNow,
rolesCount: rolesGroup.length
}
})
.get('/api/logs/operators', async () => {
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
orderBy: { name: 'asc' }
})
})
.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 defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
const actingUserId = userId || defaultAdmin?.id || ''
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: actingUserId,
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!',

View File

@@ -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' },
] ]

View File

@@ -23,4 +23,16 @@ export const API_URLS = {
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`, listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`, listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
getGlobalLogs: (page: number, search: string, type: string, userId: string) =>
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`,
getLogOperators: () => `/api/logs/operators`,
getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
getOperatorStats: () => `/api/operators/stats`,
getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`,
createLog: () => `/api/logs`,
} }

View File

@@ -1,33 +1,30 @@
import { useEffect, useState } from 'react'
import { notifications } from '@mantine/notifications'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { SummaryCard } from '@/frontend/components/SummaryCard'
import { import {
ActionIcon, Badge,
Button,
Group, Group,
Modal,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text,
Title,
Modal,
Button,
TextInput,
Switch, Switch,
Badge, Text,
Textarea, Textarea,
Skeleton TextInput,
Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { notifications } from '@mantine/notifications'
import useSWR from 'swr' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { import {
TbActivity, TbActivity,
TbAlertTriangle, TbAlertTriangle,
TbBuildingCommunity, TbBuildingCommunity,
TbRefresh,
TbVersions TbVersions
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/')({ export const Route = createFileRoute('/apps/$appId/')({
@@ -89,6 +86,12 @@ function AppOverviewPage() {
}) })
if (response.ok) { if (response.ok) {
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 })}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Update Successful', title: 'Update Successful',
message: 'Application version information has been updated.', message: 'Application version information has been updated.',

View File

@@ -172,10 +172,10 @@ function AppLogsPage() {
> >
<Table.Thead bg="rgba(0,0,0,0.05)"> <Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr> <Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '18%' }}>Timestamp</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Timestamp</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Action</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Action</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '50%' }}>Description</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '40%' }}>Description</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -187,7 +187,7 @@ function AppLogsPage() {
<TbCalendar size={14} /> <TbCalendar size={14} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0}> <Stack gap={0}>
<Text size="xs" fw={700} style={{ color: 'var(--mantine-color-white)' }}> <Text size="xs" fw={700}>
{log.createdAt.split(' ').slice(1).join(' ')} {log.createdAt.split(' ').slice(1).join(' ')}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -216,7 +216,10 @@ function AppLogsPage() {
color={getActionColor(log.action)} color={getActionColor(log.action)}
radius="sm" radius="sm"
size="xs" size="xs"
styles={{ root: { fontWeight: 800 } }} styles={{
root: { fontWeight: 800 },
label: { textOverflow: 'clip', overflow: 'visible' }
}}
> >
{log.action} {log.action}
</Badge> </Badge>

View File

@@ -169,6 +169,12 @@ function UsersIndexPage() {
const result = await res.json() const result = await res.json()
if (result.success) { if (result.success) {
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}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'User has been created successfully.', message: 'User has been created successfully.',
@@ -252,6 +258,12 @@ function UsersIndexPage() {
const result = await res.json() const result = await res.json()
if (result.success) { if (result.success) {
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}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'User has been updated successfully.', message: 'User has been updated successfully.',
@@ -639,8 +651,8 @@ function UsersIndexPage() {
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars> <ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table <Table
verticalSpacing="lg" verticalSpacing="md"
horizontalSpacing="xl" horizontalSpacing="md"
highlightOnHover highlightOnHover
withColumnBorders={false} withColumnBorders={false}
style={{ style={{
@@ -654,14 +666,13 @@ function UsersIndexPage() {
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '8%' }}>Actions</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{users.map((user) => ( {users.map((user) => (
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}> <Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }} onClick={()=>{handleEditOpen(user)}}>
<Table.Td> <Table.Td>
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">
<Avatar <Avatar
@@ -745,17 +756,6 @@ function UsersIndexPage() {
)} )}
</Stack> </Stack>
</Table.Td> </Table.Td>
<Table.Td>
<ActionIcon
variant="light"
color="brand-blue"
onClick={() => handleEditOpen(user)}
size="md"
radius="md"
>
<TbEdit size={18} />
</ActionIcon>
</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -4,14 +4,19 @@ import {
Button, Button,
Card, Card,
Group, Group,
Modal,
Paper, Paper,
SegmentedControl, SegmentedControl,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Textarea,
TextInput,
ThemeIcon, ThemeIcon,
Title Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { import {
@@ -144,12 +149,132 @@ function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' }) const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate() const navigate = useNavigate()
const { data: infoRes, isLoading: infoLoading } = useSWR(API_URLS.infoVillages(villageId), fetcher) const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher) const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', desc: '' })
const village = infoRes?.data const village = infoRes?.data
const stats = gridRes?.data const stats = gridRes?.data
const openEdit = () => {
setEditForm({
name: village?.name || '',
desc: village?.desc || ''
})
openEditModal()
}
const handleEditVillage = async () => {
if (!village) return
if (!editForm.name.trim() || !editForm.desc.trim()) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required.',
color: 'red'
})
return
}
setIsEditing(true)
try {
const res = await fetch(API_URLS.editVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
name: editForm.name,
desc: editForm.desc
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Data desa (${appId}) diperbarui: ${editForm.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'Village data has been updated successfully.',
color: 'teal'
})
mutate()
closeEditModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village data.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsEditing(false)
}
}
const handleConfirmToggle = async () => {
if (!village) return
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateStatusVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
active: !village.isActive
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Status desa (${appId}) diperbarui (${!village.isActive ? 'activated' : 'deactivated'}): ${village.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
color: 'teal'
})
mutate()
closeConfirmModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village status.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsUpdating(false)
}
}
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } }) const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) { if (infoLoading || gridLoading) {
@@ -195,8 +320,9 @@ function VillageDetailPage() {
variant="filled" variant="filled"
color={village.isActive ? 'red' : 'green'} color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />} leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)} onClick={openConfirmModal}
radius="md" radius="md"
loading={isUpdating}
> >
{village.isActive ? 'Deactivate' : 'Active'} {village.isActive ? 'Deactivate' : 'Active'}
</Button> </Button>
@@ -204,7 +330,7 @@ function VillageDetailPage() {
variant="light" variant="light"
color="blue" color="blue"
leftSection={<TbEdit size={16} />} leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)} onClick={openEdit}
radius="md" radius="md"
> >
Edit Edit
@@ -347,6 +473,75 @@ function VillageDetailPage() {
</Paper> </Paper>
</Box> </Box>
{/* ── Confirmation Modal ── */}
<Modal
opened={confirmModalOpened}
onClose={closeConfirmModal}
title={<Text fw={700}>Confirm Status Change</Text>}
radius="xl"
centered
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
Cancel
</Button>
<Button
color={village.isActive ? 'red' : 'green'}
onClick={handleConfirmToggle}
loading={isUpdating}
radius="md"
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
{/* ── Edit Village Modal ── */}
<Modal
opened={editModalOpened}
onClose={closeEditModal}
title={<Text fw={700}>Edit Village Details</Text>}
radius="xl"
size="md"
>
<Stack gap="md">
<TextInput
label="Village Name"
placeholder="Enter village name"
required
value={editForm.name}
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Enter village description..."
minRows={3}
required
value={editForm.desc}
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
Cancel
</Button>
<Button
variant="filled"
color="blue"
onClick={handleEditVillage}
loading={isEditing}
radius="md"
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
) )
} }

View File

@@ -283,6 +283,12 @@ function AppVillagesIndexPage() {
}) })
if (res.ok) { if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `Desa baru didaftarkan: ${form.name}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Village has been successfully registered.', message: 'Village has been successfully registered.',

View File

@@ -0,0 +1,484 @@
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) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `Report bug baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
}).catch(console.error)
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?.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>
)
}

View File

@@ -1,23 +1,23 @@
import { useQuery } from '@tanstack/react-query' import { AppCard } from '@/frontend/components/AppCard'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { useSession } from '@/frontend/hooks/useAuth'
import { import {
Badge, Badge,
Button, Button,
Container, Container,
Group, Group,
Loader,
Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table,
Text, Text,
Title, Title,
Paper,
Table,
Loader,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute, redirect, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query'
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb' import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { AppCard } from '@/frontend/components/AppCard'
export const Route = createFileRoute('/dashboard')({ export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -65,7 +65,7 @@ function DashboardPage() {
<Title order={2} className="gradient-text">Overview Dashboard</Title> <Title order={2} className="gradient-text">Overview Dashboard</Title>
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text> <Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
</Stack> </Stack>
<Button {/* <Button
variant="gradient" variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbApps size={18} />} leftSection={<TbApps size={18} />}
@@ -74,7 +74,7 @@ function DashboardPage() {
to="/apps" to="/apps"
> >
Manage All Apps Manage All Apps
</Button> </Button> */}
</Group> </Group>
{statsLoading ? ( {statsLoading ? (
@@ -107,8 +107,8 @@ function DashboardPage() {
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Title order={3}>Registered Applications</Title> <Title order={3}>Registered Applications</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}> <Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/apps">
View Report View All Apps
</Button> </Button>
</Group> </Group>

View File

@@ -10,67 +10,119 @@ import {
Avatar, Avatar,
Box, Box,
Divider, Divider,
Pagination,
Center,
Tooltip,
} from '@mantine/core' } from '@mantine/core'
import { useState, useMemo } from 'react' import { useState, useMemo, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb' import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/logs')({ export const Route = createFileRoute('/logs')({
component: GlobalLogsPage, component: GlobalLogsPage,
}) })
const timelineData = [ const fetcher = (url: string) => fetch(url).then((res) => res.json())
{
date: 'TODAY', const typeConfig: Record<string, { color: string; icon?: any }> = {
logs: [ CREATE: { color: 'blue', icon: TbCheck },
{ id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document <Badge variant="light" color="gray" radius="sm">Surat Domisili</Badge> for <Badge variant="light" color="blue" radius="sm">Sukatani</Badge></> }, UPDATE: { color: 'teal', icon: TbCheck },
{ id: 2, time: '11:42 AM', operator: 'Siti Aminah', app: 'Desa+', color: 'teal', content: <>uploaded financial report <Badge variant="light" color="gray" radius="sm">Realisasi Q1</Badge> for <Badge variant="light" color="teal" radius="sm">Sukamaju</Badge></> }, DELETE: { color: 'red', icon: TbX },
{ id: 3, time: '10:12 AM', operator: 'System', app: 'Desa+', color: 'red', icon: TbX, content: <>experienced failure in <Badge variant="light" color="violet" radius="sm">SIAK Sync</Badge> at <Badge variant="light" color="red" radius="sm" leftSection={<TbX size={12}/>}>Cikini</Badge></>, message: { title: 'Sync Operation Failed (NullPointerException)', text: 'NullPointerException at village_sync.dart:45. The server returned a timeout error while waiting for the master database replica connection. Auto-retry scheduled in 15 minutes.' } }, LOGIN: { color: 'green', icon: TbClock },
{ id: 4, time: '09:42 AM', operator: 'Jane Smith', app: 'E-Commerce', color: 'orange', icon: TbCheck, content: <>resolved payment gateway issue for <Badge variant="light" color="orange" radius="sm">E-Commerce</Badge> checkout</> }, LOGOUT: { color: 'orange', icon: TbClock },
] }
},
{ const getRoleColor = (role: string) => {
date: 'YESTERDAY', const r = (role || '').toLowerCase()
logs: [ if (r.includes('super')) return 'red'
{ id: 5, time: '05:10 AM', operator: 'System', app: 'System', color: 'cyan', content: <>completed automated <Badge variant="light" color="cyan" radius="sm">Nightly Backup</Badge> for all 138 villages</> }, if (r.includes('admin')) return 'brand-blue'
{ id: 6, time: '04:50 AM', operator: 'Rahmat Hidayat', app: 'Desa+', color: 'green', content: <>granted Admin access to <Text component="span" fw={600}>Desa Bojong Gede</Text> operator</> }, if (r.includes('developer')) return 'violet'
{ id: 7, time: '03:42 AM', operator: 'System', app: 'Fitness App', color: 'red', icon: TbX, content: <>detected SocketException across <Badge variant="light" color="violet" radius="sm">Fitness App</Badge> wearable sync operations.</> }, return 'gray'
{ id: 8, time: '02:33 AM', operator: 'Agus Setiawan', app: 'Desa+', color: 'blue', content: <>verified 145 <Badge variant="light" color="gray" radius="sm">Surat Kematian</Badge> entries in batch.</> }, }
]
}, function groupLogsByDate(logs: any[]) {
{ const groups: Record<string, any[]> = {}
date: '12 APRIL, 2026',
logs: [ const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
{ id: 9, time: '03:42 AM', operator: 'Amel', app: 'Desa+', color: 'indigo', content: <>changed version configurations rolling out <Badge variant="light" color="gray" radius="sm">Desa+ v2.4.1</Badge></> }, const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
{ id: 10, time: '02:10 AM', operator: 'John Doe', app: 'E-Commerce', color: 'pink', content: <>updated App setting <Badge variant="light" color="gray" radius="sm">Require OTP on Login</Badge> <Text component="span" c="violet" fw={600} size="sm" style={{ cursor: 'pointer' }}>View Details</Text></> },
] logs.forEach(log => {
const dateObj = new Date(log.createdAt)
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!groups[dateStr]) groups[dateStr] = []
const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
groups[dateStr].push({
id: log.id,
time: timeStr,
user: log.user,
type: log.type,
content: log.message,
color: log.user ? getRoleColor(log.user.role) : 'gray',
icon: typeConfig[log.type as string]?.icon
})
})
// We want to keep the order as they came from the API (sorted by createdAt desc)
// but grouped by date. Object.entries might mess up the order if dates are not sequential.
// However, since the source logs are sorted, the first encounter of a date defines the group order.
const result: { date: string; logs: any[] }[] = []
const seenDates = new Set<string>()
logs.forEach(log => {
const dateObj = new Date(log.createdAt)
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!seenDates.has(dateStr)) {
result.push({ date: dateStr, logs: groups[dateStr] })
seenDates.add(dateStr)
} }
] })
return result
}
function GlobalLogsPage() { function GlobalLogsPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appFilter, setAppFilter] = useState<string | null>(null) const [debouncedSearch, setDebouncedSearch] = useState('')
const [operatorFilter, setOperatorFilter] = useState<string | null>(null) const [logType, setLogType] = useState<string | null>('all')
const [operatorId, setOperatorId] = useState<string | null>('all')
const [page, setPage] = useState(1)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
const operatorOptions = useMemo(() => {
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
return [
{ value: 'all', label: 'All Operators' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
]
}, [operatorsData])
const { data: response, isLoading } = useSWR(
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
fetcher
)
const filteredTimeline = useMemo(() => { const filteredTimeline = useMemo(() => {
return timelineData if (!response?.data) return []
.map(group => { return groupLogsByDate(response.data)
const filteredLogs = group.logs.filter(log => { }, [response?.data])
if (appFilter && log.app !== appFilter) return false;
if (operatorFilter && log.operator !== operatorFilter) return false;
if (search) {
const lSearch = search.toLowerCase();
if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
return false;
}
}
return true;
});
return { ...group, logs: filteredLogs };
})
.filter(group => group.logs.length > 0);
}, [search, appFilter, operatorFilter]);
return ( return (
<DashboardLayout> <DashboardLayout>
@@ -79,38 +131,59 @@ function GlobalLogsPage() {
{/* Header Controls */} {/* Header Controls */}
<Group mb="xl" gap="md"> <Group mb="xl" gap="md">
<TextInput <TextInput
placeholder="Search operator or app..." placeholder="Search operator or message..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
radius="md" radius="md"
w={220} w={250}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/> />
<Select <Select
placeholder="All Applications" placeholder="Log Type"
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']} data={[
{ value: 'all', label: 'All Types' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
]}
radius="md" radius="md"
w={160} w={160}
clearable value={logType}
value={appFilter} onChange={(val) => {
onChange={setAppFilter} setLogType(val)
setPage(1)
}}
/> />
<Select <Select
placeholder="All Operators" placeholder="Operator"
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']} data={operatorOptions}
searchable
radius="md" radius="md"
w={160} w={200}
clearable value={operatorId}
value={operatorFilter} onChange={(val) => {
onChange={setOperatorFilter} setOperatorId(val)
setPage(1)
}}
/> />
</Group> </Group>
{/* Timeline Content */} {/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}> <Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{filteredTimeline.length === 0 ? ( {isLoading ? (
<Center py="xl">
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text> <Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : filteredTimeline.map((group, groupIndex) => ( ) : (
<>
{filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}> <Box key={group.date}>
<Text <Text
size="xs" size="xs"
@@ -163,50 +236,51 @@ function GlobalLogsPage() {
)} )}
{/* Avatar */} {/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}> <Box style={{ position: 'relative', zIndex: 2 }}>
{log.icon ? ( <Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
<Avatar size={24} radius="xl" color={log.color} variant="light"> <Avatar
<log.icon size={14} /> size={24}
radius="xl"
color={log.color}
variant="light"
src={log.user?.image}
style={{ cursor: 'help' }}
>
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
</Avatar> </Avatar>
) : ( </Tooltip>
<Avatar size={24} radius="xl" color={log.color}>
{log.operator.charAt(0)}
</Avatar>
)}
</Box> </Box>
</Box> </Box>
{/* Right: Content */} {/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}> <Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm"> <Text size="sm">
<Text component="span" fw={600} mr={4}>{log.operator}</Text> <Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
{log.content} {log.content}
</Text> </Text>
{log.message && (
<Paper
withBorder
p="md"
radius="md"
mt="sm"
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
>
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
<Text size="sm" c="dimmed">
{log.message.text}
</Text>
</Paper>
)}
</Box> </Box>
</Group> </Group>
) )
})} })}
</Stack> </Stack>
{groupIndex < timelineData.length - 1 && ( {groupIndex < filteredTimeline.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" /> <Divider my="xl" color="rgba(128,128,128,0.1)" />
)} )}
</Box> </Box>
))} ))}
{response?.totalPages > 1 && (
<Center mt="xl">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Center>
)}
</>
)}
</Paper> </Paper>
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

@@ -16,10 +16,11 @@ import {
SimpleGrid, SimpleGrid,
ThemeIcon, ThemeIcon,
List, List,
Box,
Divider, Divider,
Pagination,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { import {
TbPlus, TbPlus,
TbSearch, TbSearch,
@@ -34,40 +35,59 @@ import {
} from 'react-icons/tb' } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard' import { StatsCard } from '@/frontend/components/StatsCard'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/users')({ export const Route = createFileRoute('/users')({
component: UsersPage, component: UsersPage,
}) })
const mockUsers = [ const fetcher = (url: string) => fetch(url).then((res) => res.json())
{ id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
{ id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' }, const getRoleColor = (role: string) => {
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' }, const r = (role || '').toLowerCase()
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' }, if (r.includes('super')) return 'red'
] if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
const roles = [ const roles = [
{ {
name: 'SUPER_ADMIN', name: 'SUPER_ADMIN',
count: 2,
color: 'red', color: 'red',
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors'] permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
}, },
{ {
name: 'DEVELOPER', name: 'DEVELOPER',
count: 12,
color: 'brand-blue', color: 'brand-blue',
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup'] permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
}, },
{ {
name: 'QA', name: 'QA',
count: 5,
color: 'orange', color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features'] permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
}, },
] ]
function UsersPage() { function UsersPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [page, setPage] = useState(1)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher)
const { data: response, isLoading } = useSWR(
API_URLS.getOperators(page, debouncedSearch),
fetcher
)
const operators = response?.data || []
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -80,9 +100,9 @@ function UsersPage() {
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" /> <StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" /> <StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" /> <StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
</SimpleGrid> </SimpleGrid>
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md"> <Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
@@ -100,6 +120,11 @@ function UsersPage() {
radius="md" radius="md"
w={350} w={350}
variant="filled" variant="filled"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/> />
<Button <Button
variant="gradient" variant="gradient"
@@ -117,17 +142,31 @@ function UsersPage() {
<Table.Tr> <Table.Tr>
<Table.Th>Name & Contact</Table.Th> <Table.Th>Name & Contact</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>Joined Date</Table.Th>
<Table.Th>App Access</Table.Th>
<Table.Th>Actions</Table.Th> <Table.Th>Actions</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{mockUsers.map((user) => ( {isLoading ? (
<Table.Tr>
<Table.Td colSpan={4} align="center">
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
</Table.Td>
</Table.Tr>
) : operators.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4} align="center">
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
</Table.Td>
</Table.Tr>
) : (
operators.map((user: any) => (
<Table.Tr key={user.id}> <Table.Tr key={user.id}>
<Table.Td> <Table.Td>
<Group gap="sm"> <Group gap="sm">
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar> <Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
<Stack gap={0}> <Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text> <Text fw={600} size="sm">{user.name}</Text>
<Text size="xs" c="dimmed">{user.email}</Text> <Text size="xs" c="dimmed">{user.email}</Text>
@@ -135,22 +174,12 @@ function UsersPage() {
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}> <Badge variant="light" color={getRoleColor(user.role)}>
{user.role} {user.role}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={6}> <Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
<Text size="xs" fw={500}>{user.status}</Text>
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={4}>
<TbApps size={12} color="gray" />
<Text size="xs" fw={500}>{user.apps}</Text>
</Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
@@ -163,10 +192,22 @@ function UsersPage() {
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))
)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Paper> </Paper>
{response?.totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Group>
)}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -179,7 +220,6 @@ function UsersPage() {
<ThemeIcon size="xl" radius="md" color={role.color} variant="light"> <ThemeIcon size="xl" radius="md" color={role.color} variant="light">
<TbShieldCheck size={28} /> <TbShieldCheck size={28} />
</ThemeIcon> </ThemeIcon>
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
</Group> </Group>
<Stack gap={4}> <Stack gap={4}>

18
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,18 @@
import { prisma } from './db'
import { LogType } from '../../generated/prisma'
export async function createSystemLog(userId: string, type: LogType, message: string) {
try {
return await prisma.log.create({
data: {
userId,
type,
message,
},
})
} catch (error) {
console.error('[Logger Error]', error)
// Don't throw, we don't want logging errors to break the main application flow
return null
}
}