Compare commits
10 Commits
amalia/09-
...
amalia/13-
| Author | SHA1 | Date | |
|---|---|---|---|
| 65e9ed5ce6 | |||
| 2cf96135f9 | |||
| 14a9e2c687 | |||
| c0205ce2bf | |||
| 315ecc565e | |||
| 8c50768c98 | |||
| 5cc73d2290 | |||
| ac3c673500 | |||
| e1b9241c35 | |||
| 7b23192121 |
12
prisma/migrations/20260413034305_add/migration.sql
Normal file
12
prisma/migrations/20260413034305_add/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "image" TEXT;
|
||||
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
@@ -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";
|
||||
@@ -15,10 +15,6 @@ enum Role {
|
||||
DEVELOPER
|
||||
}
|
||||
|
||||
enum App{
|
||||
desa_plus
|
||||
hipmi
|
||||
}
|
||||
|
||||
enum BugSource{
|
||||
QC
|
||||
@@ -35,6 +31,14 @@ enum BugStatus{
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum LogType{
|
||||
CREATE
|
||||
UPDATE
|
||||
DELETE
|
||||
LOGIN
|
||||
LOGOUT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@@ -42,6 +46,7 @@ model User {
|
||||
password String
|
||||
role Role @default(USER)
|
||||
active Boolean @default(true)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -69,7 +74,7 @@ model Session {
|
||||
model Log {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
type String
|
||||
type LogType
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -81,7 +86,7 @@ model Log {
|
||||
model Bug {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
app App
|
||||
app String?
|
||||
affectedVersion String
|
||||
device String
|
||||
os String
|
||||
@@ -116,13 +121,13 @@ model BugImage {
|
||||
model BugLog {
|
||||
id String @id @default(uuid())
|
||||
bugId String
|
||||
userId String
|
||||
userId String?
|
||||
status BugStatus
|
||||
description String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
224
src/app.ts
224
src/app.ts
@@ -3,6 +3,7 @@ import { html } from '@elysiajs/html'
|
||||
import { Elysia } from 'elysia'
|
||||
import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
import { createSystemLog } from './lib/logger'
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
@@ -43,13 +44,20 @@ export function createApp() {
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||
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 } }
|
||||
})
|
||||
|
||||
.post('/api/auth/logout', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
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'
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -139,6 +147,8 @@ export function createApp() {
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
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.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
|
||||
})
|
||||
@@ -153,8 +163,8 @@ export function createApp() {
|
||||
|
||||
.get('/api/apps', () => [
|
||||
{ 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: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
|
||||
// { 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' },
|
||||
])
|
||||
|
||||
.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' }
|
||||
})
|
||||
|
||||
.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 ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
TbSun,
|
||||
TbMoon,
|
||||
TbUser,
|
||||
TbHistory
|
||||
TbHistory,
|
||||
TbBug
|
||||
} from 'react-icons/tb'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -52,6 +53,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
||||
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
||||
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
|
||||
{ label: 'Bug Reports', icon: TbBug, to: '/bug-reports' },
|
||||
{ label: 'Users', icon: TbUser, to: '/users' },
|
||||
]
|
||||
|
||||
|
||||
@@ -18,4 +18,21 @@ export const API_URLS = {
|
||||
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
|
||||
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
|
||||
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
|
||||
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
|
||||
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
|
||||
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}`,
|
||||
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`,
|
||||
}
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
||||
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Modal,
|
||||
Button,
|
||||
TextInput,
|
||||
Switch,
|
||||
Badge,
|
||||
Text,
|
||||
Textarea,
|
||||
Skeleton
|
||||
TextInput,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
|
||||
import useSWR from 'swr'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
TbActivity,
|
||||
TbAlertTriangle,
|
||||
TbBuildingCommunity,
|
||||
TbRefresh,
|
||||
TbVersions
|
||||
} from 'react-icons/tb'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/')({
|
||||
@@ -89,6 +86,12 @@ function AppOverviewPage() {
|
||||
})
|
||||
|
||||
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({
|
||||
title: 'Update Successful',
|
||||
message: 'Application version information has been updated.',
|
||||
@@ -118,29 +121,29 @@ function AppOverviewPage() {
|
||||
<>
|
||||
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Active Version"
|
||||
<TextInput
|
||||
label="Active Version"
|
||||
placeholder="e.g. 2.0.5"
|
||||
value={latestVersion}
|
||||
value={latestVersion}
|
||||
onChange={(e) => setLatestVersion(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Minimum Version"
|
||||
<TextInput
|
||||
label="Minimum Version"
|
||||
placeholder="e.g. 2.0.0"
|
||||
value={minVersion}
|
||||
value={minVersion}
|
||||
onChange={(e) => setMinVersion(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Update Message"
|
||||
placeholder="Enter release notes or update message..."
|
||||
<Textarea
|
||||
label="Update Message"
|
||||
placeholder="Enter release notes or update message..."
|
||||
value={messageUpdate}
|
||||
onChange={(e) => setMessageUpdate(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Switch
|
||||
label="Maintenance Mode"
|
||||
description="Enable to put the app in maintenance mode for users."
|
||||
<Switch
|
||||
label="Maintenance Mode"
|
||||
description="Enable to put the app in maintenance mode for users."
|
||||
checked={maintenance}
|
||||
onChange={(e) => setMaintenance(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
@@ -112,7 +112,7 @@ function AppLogsPage() {
|
||||
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<TbDownload size={18} />}
|
||||
@@ -120,7 +120,7 @@ function AppLogsPage() {
|
||||
size="md"
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -172,10 +172,10 @@ function AppLogsPage() {
|
||||
>
|
||||
<Table.Thead bg="rgba(0,0,0,0.05)">
|
||||
<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 : '12%' }}>Action</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '50%' }}>Description</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Action</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '40%' }}>Description</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -187,7 +187,7 @@ function AppLogsPage() {
|
||||
<TbCalendar size={14} />
|
||||
</ThemeIcon>
|
||||
<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(' ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -216,7 +216,10 @@ function AppLogsPage() {
|
||||
color={getActionColor(log.action)}
|
||||
radius="sm"
|
||||
size="xs"
|
||||
styles={{ root: { fontWeight: 800 } }}
|
||||
styles={{
|
||||
root: { fontWeight: 800 },
|
||||
label: { textOverflow: 'clip', overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</Badge>
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Button,
|
||||
ActionIcon,
|
||||
TextInput,
|
||||
Table,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Pagination,
|
||||
ThemeIcon,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Switch,
|
||||
} from '@mantine/core'
|
||||
import { useMediaQuery } from '@mantine/hooks'
|
||||
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
TbBriefcase,
|
||||
TbCircleCheck,
|
||||
TbCircleX,
|
||||
TbEdit,
|
||||
TbHome2,
|
||||
TbId,
|
||||
TbMail,
|
||||
TbPhone,
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
TbUsers,
|
||||
TbX,
|
||||
TbMail,
|
||||
TbPhone,
|
||||
TbId,
|
||||
TbBriefcase,
|
||||
TbHome2,
|
||||
TbCircleCheck,
|
||||
TbCircleX,
|
||||
} from 'react-icons/tb'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/users/')({
|
||||
@@ -46,12 +52,17 @@ interface APIUser {
|
||||
nik: string
|
||||
phone: string
|
||||
email: string
|
||||
gender: string
|
||||
isWithoutOTP: boolean
|
||||
isActive: boolean
|
||||
role: string
|
||||
village: string
|
||||
group: string
|
||||
position?: string
|
||||
idUserRole: string
|
||||
idVillage: string
|
||||
idGroup: string
|
||||
idPosition: string
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
@@ -65,7 +76,7 @@ function UsersIndexPage() {
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
|
||||
|
||||
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
|
||||
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
|
||||
const users: APIUser[] = response?.data?.user || []
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
@@ -76,6 +87,210 @@ function UsersIndexPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADD USER LOGIC ---
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [villageSearch, setVillageSearch] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
nik: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: '',
|
||||
idUserRole: '',
|
||||
idVillage: '',
|
||||
idGroup: '',
|
||||
idPosition: ''
|
||||
})
|
||||
|
||||
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
nik: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: '',
|
||||
idUserRole: '',
|
||||
idVillage: '',
|
||||
idGroup: '',
|
||||
idPosition: '',
|
||||
isActive: true,
|
||||
isWithoutOTP: false
|
||||
})
|
||||
|
||||
// Options Data (Shared for both Add and Edit modals)
|
||||
const isAnyModalOpened = opened || editOpened
|
||||
const targetVillageId = opened ? form.idVillage : editForm.idVillage
|
||||
const targetGroupId = opened ? form.idGroup : editForm.idGroup
|
||||
|
||||
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher)
|
||||
const { data: villagesResp } = useSWR(
|
||||
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
|
||||
fetcher
|
||||
)
|
||||
const { data: groupsResp } = useSWR(
|
||||
isAnyModalOpened && targetVillageId ? API_URLS.listGroup(targetVillageId) : null,
|
||||
fetcher
|
||||
)
|
||||
const { data: positionsResp } = useSWR(
|
||||
isAnyModalOpened && targetGroupId ? API_URLS.listPosition(targetGroupId) : null,
|
||||
fetcher
|
||||
)
|
||||
|
||||
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name }))
|
||||
const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
|
||||
const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name }))
|
||||
const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
|
||||
const missing = requiredFields.filter(f => !form[f as keyof typeof form])
|
||||
|
||||
if (missing.length > 0) {
|
||||
notifications.show({
|
||||
title: 'Validation Error',
|
||||
message: `Please fill in all required fields: ${missing.join(', ')}`,
|
||||
color: 'red'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(API_URLS.createUser(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
||||
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({
|
||||
title: 'Success',
|
||||
message: 'User has been created successfully.',
|
||||
color: 'teal',
|
||||
icon: <TbCircleCheck size={18} />
|
||||
})
|
||||
mutate() // Refresh user list
|
||||
close()
|
||||
setForm({
|
||||
name: '',
|
||||
nik: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: '',
|
||||
idUserRole: '',
|
||||
idVillage: '',
|
||||
idGroup: '',
|
||||
idPosition: ''
|
||||
})
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: result.message || 'Failed to create user.',
|
||||
color: 'red',
|
||||
icon: <TbCircleX size={18} />
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
title: 'Network Error',
|
||||
message: 'Unable to connect to the server.',
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditOpen = (user: APIUser) => {
|
||||
setEditForm({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
nik: user.nik,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
gender: user.gender,
|
||||
idUserRole: user.idUserRole,
|
||||
idVillage: user.idVillage,
|
||||
idGroup: user.idGroup,
|
||||
idPosition: user.idPosition,
|
||||
isActive: user.isActive,
|
||||
isWithoutOTP: user.isWithoutOTP
|
||||
})
|
||||
setVillageSearch(user.village)
|
||||
openEdit()
|
||||
}
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
|
||||
const missing = requiredFields.filter(f => !editForm[f as keyof typeof editForm])
|
||||
|
||||
if (missing.length > 0) {
|
||||
notifications.show({
|
||||
title: 'Validation Error',
|
||||
message: `Please fill in all required fields: ${missing.join(', ')}`,
|
||||
color: 'red'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(API_URLS.editUser(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(editForm)
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
||||
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({
|
||||
title: 'Success',
|
||||
message: 'User has been updated successfully.',
|
||||
color: 'teal',
|
||||
icon: <TbCircleCheck size={18} />
|
||||
})
|
||||
mutate()
|
||||
closeEdit()
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: result.message || 'Failed to update user.',
|
||||
color: 'red',
|
||||
icon: <TbCircleX size={18} />
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
title: 'Network Error',
|
||||
message: 'Unable to connect to the server.',
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearch('')
|
||||
setSearchQuery('')
|
||||
@@ -126,11 +341,279 @@ function UsersIndexPage() {
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={open}
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={<Text fw={700} size="lg">Add New User</Text>}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Personal Information
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Enter full name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="16-digit identity number"
|
||||
required
|
||||
value={form.nik}
|
||||
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone Number"
|
||||
placeholder="628xxxxxxxxxx"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select gender"
|
||||
data={[
|
||||
{ value: 'M', label: 'Male' },
|
||||
{ value: 'F', label: 'Female' },
|
||||
]}
|
||||
mt="sm"
|
||||
required
|
||||
value={form.gender}
|
||||
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider label="Role & Organization" labelPosition="center" my="sm" />
|
||||
|
||||
<Box>
|
||||
<Select
|
||||
label="User Role"
|
||||
placeholder="Select user role"
|
||||
data={rolesOptions}
|
||||
required
|
||||
value={form.idUserRole}
|
||||
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Village"
|
||||
placeholder="Type to search village..."
|
||||
searchable
|
||||
onSearchChange={setVillageSearch}
|
||||
data={villagesOptions}
|
||||
mt="sm"
|
||||
required
|
||||
value={form.idVillage}
|
||||
onChange={(v) => {
|
||||
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||
<Select
|
||||
label="Group"
|
||||
placeholder={form.idVillage ? "Select group" : "Select village first"}
|
||||
data={groupsOptions}
|
||||
disabled={!form.idVillage}
|
||||
required
|
||||
value={form.idGroup}
|
||||
onChange={(v) => {
|
||||
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Position"
|
||||
placeholder={form.idGroup ? "Select position" : "Select group first"}
|
||||
data={positionsOptions}
|
||||
disabled={!form.idGroup}
|
||||
value={form.idPosition || ''}
|
||||
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt="lg"
|
||||
radius="md"
|
||||
size="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
loading={isSubmitting}
|
||||
onClick={handleCreateUser}
|
||||
>
|
||||
Register User
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={editOpened}
|
||||
onClose={closeEdit}
|
||||
title={<Text fw={700} size="lg">Edit User</Text>}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Personal Information
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Enter full name"
|
||||
required
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="16-digit identity number"
|
||||
required
|
||||
value={editForm.nik}
|
||||
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone Number"
|
||||
placeholder="628xxxxxxxxxx"
|
||||
required
|
||||
value={editForm.phone}
|
||||
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select gender"
|
||||
data={[
|
||||
{ value: 'M', label: 'Male' },
|
||||
{ value: 'F', label: 'Female' },
|
||||
]}
|
||||
mt="sm"
|
||||
required
|
||||
value={editForm.gender}
|
||||
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider label="Role & Organization" labelPosition="center" my="sm" />
|
||||
|
||||
<Box>
|
||||
<Select
|
||||
label="User Role"
|
||||
placeholder="Select user role"
|
||||
data={rolesOptions}
|
||||
required
|
||||
value={editForm.idUserRole}
|
||||
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Village"
|
||||
placeholder="Type to search village..."
|
||||
searchable
|
||||
onSearchChange={setVillageSearch}
|
||||
data={villagesOptions}
|
||||
mt="sm"
|
||||
required
|
||||
value={editForm.idVillage}
|
||||
onChange={(v) => {
|
||||
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||
<Select
|
||||
label="Group"
|
||||
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
|
||||
data={groupsOptions}
|
||||
disabled={!editForm.idVillage}
|
||||
required
|
||||
value={editForm.idGroup}
|
||||
onChange={(v) => {
|
||||
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Position"
|
||||
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
|
||||
data={positionsOptions}
|
||||
disabled={!editForm.idGroup}
|
||||
value={editForm.idPosition || ''}
|
||||
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider label="System Access" labelPosition="center" my="sm" />
|
||||
|
||||
<SimpleGrid cols={2} spacing="xl">
|
||||
<Switch
|
||||
label="Account Active"
|
||||
description="Enable or disable user access"
|
||||
checked={editForm.isActive}
|
||||
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
|
||||
/>
|
||||
<Switch
|
||||
label="Without OTP"
|
||||
description="Bypass login OTP verification"
|
||||
checked={editForm.isWithoutOTP}
|
||||
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt="lg"
|
||||
radius="md"
|
||||
size="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
loading={isSubmitting}
|
||||
onClick={handleUpdateUser}
|
||||
>
|
||||
Update User
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search name, NIK, or email..."
|
||||
leftSection={<TbSearch size={18} />}
|
||||
@@ -167,13 +650,13 @@ function UsersIndexPage() {
|
||||
) : (
|
||||
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
|
||||
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
|
||||
<Table
|
||||
verticalSpacing="lg"
|
||||
horizontalSpacing="xl"
|
||||
highlightOnHover
|
||||
<Table
|
||||
verticalSpacing="md"
|
||||
horizontalSpacing="md"
|
||||
highlightOnHover
|
||||
withColumnBorders={false}
|
||||
style={{
|
||||
tableLayout: isMobile ? 'auto' : 'fixed',
|
||||
style={{
|
||||
tableLayout: isMobile ? 'auto' : 'fixed',
|
||||
width: '100%',
|
||||
minWidth: isMobile ? 900 : 'unset'
|
||||
}}
|
||||
@@ -182,19 +665,19 @@ function UsersIndexPage() {
|
||||
<Table.Tr>
|
||||
<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%' }}>Organization</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
|
||||
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</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.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{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>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar
|
||||
size="lg"
|
||||
radius="md"
|
||||
<Avatar
|
||||
size="lg"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={getRoleColor(user.role)}
|
||||
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
|
||||
@@ -243,10 +726,10 @@ function UsersIndexPage() {
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="filled"
|
||||
color={getRoleColor(user.role)}
|
||||
radius="md"
|
||||
<Badge
|
||||
variant="filled"
|
||||
color={getRoleColor(user.role)}
|
||||
radius="md"
|
||||
size="sm"
|
||||
fullWidth={false}
|
||||
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
|
||||
|
||||
@@ -4,14 +4,19 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -144,12 +149,132 @@ function VillageDetailPage() {
|
||||
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
||||
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 [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 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 } })
|
||||
|
||||
if (infoLoading || gridLoading) {
|
||||
@@ -195,8 +320,9 @@ function VillageDetailPage() {
|
||||
variant="filled"
|
||||
color={village.isActive ? 'red' : 'green'}
|
||||
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
|
||||
onClick={() => alert(`Toggle status for ${village.name}`)}
|
||||
onClick={openConfirmModal}
|
||||
radius="md"
|
||||
loading={isUpdating}
|
||||
>
|
||||
{village.isActive ? 'Deactivate' : 'Active'}
|
||||
</Button>
|
||||
@@ -204,7 +330,7 @@ function VillageDetailPage() {
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<TbEdit size={16} />}
|
||||
onClick={() => alert(`Edit settings for ${village.name}`)}
|
||||
onClick={openEdit}
|
||||
radius="md"
|
||||
>
|
||||
Edit
|
||||
@@ -347,6 +473,75 @@ function VillageDetailPage() {
|
||||
</Paper>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,6 +283,12 @@ function AppVillagesIndexPage() {
|
||||
})
|
||||
|
||||
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({
|
||||
title: 'Success',
|
||||
message: 'Village has been successfully registered.',
|
||||
|
||||
484
src/frontend/routes/bug-reports.tsx
Normal file
484
src/frontend/routes/bug-reports.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
Loader,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect, Link } from '@tanstack/react-router'
|
||||
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
import { AppCard } from '@/frontend/components/AppCard'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
||||
import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -65,7 +65,7 @@ function DashboardPage() {
|
||||
<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>
|
||||
</Stack>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbApps size={18} />}
|
||||
@@ -74,7 +74,7 @@ function DashboardPage() {
|
||||
to="/apps"
|
||||
>
|
||||
Manage All Apps
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Group>
|
||||
|
||||
{statsLoading ? (
|
||||
@@ -107,8 +107,8 @@ function DashboardPage() {
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Title order={3}>Registered Applications</Title>
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
||||
View Report
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/apps">
|
||||
View All Apps
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -10,67 +10,119 @@ import {
|
||||
Avatar,
|
||||
Box,
|
||||
Divider,
|
||||
Pagination,
|
||||
Center,
|
||||
Tooltip,
|
||||
} from '@mantine/core'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
|
||||
export const Route = createFileRoute('/logs')({
|
||||
component: GlobalLogsPage,
|
||||
})
|
||||
|
||||
const timelineData = [
|
||||
{
|
||||
date: 'TODAY',
|
||||
logs: [
|
||||
{ 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></> },
|
||||
{ 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></> },
|
||||
{ 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.' } },
|
||||
{ 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</> },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: 'YESTERDAY',
|
||||
logs: [
|
||||
{ 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</> },
|
||||
{ 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</> },
|
||||
{ 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.</> },
|
||||
{ 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.</> },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '12 APRIL, 2026',
|
||||
logs: [
|
||||
{ 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></> },
|
||||
{ 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></> },
|
||||
]
|
||||
}
|
||||
]
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
const typeConfig: Record<string, { color: string; icon?: any }> = {
|
||||
CREATE: { color: 'blue', icon: TbCheck },
|
||||
UPDATE: { color: 'teal', icon: TbCheck },
|
||||
DELETE: { color: 'red', icon: TbX },
|
||||
LOGIN: { color: 'green', icon: TbClock },
|
||||
LOGOUT: { color: 'orange', icon: TbClock },
|
||||
}
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
const r = (role || '').toLowerCase()
|
||||
if (r.includes('super')) return 'red'
|
||||
if (r.includes('admin')) return 'brand-blue'
|
||||
if (r.includes('developer')) return 'violet'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function groupLogsByDate(logs: any[]) {
|
||||
const groups: Record<string, any[]> = {}
|
||||
|
||||
const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||
|
||||
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() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appFilter, setAppFilter] = useState<string | null>(null)
|
||||
const [operatorFilter, setOperatorFilter] = useState<string | null>(null)
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
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(() => {
|
||||
return timelineData
|
||||
.map(group => {
|
||||
const filteredLogs = group.logs.filter(log => {
|
||||
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]);
|
||||
if (!response?.data) return []
|
||||
return groupLogsByDate(response.data)
|
||||
}, [response?.data])
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@@ -79,134 +131,156 @@ function GlobalLogsPage() {
|
||||
{/* Header Controls */}
|
||||
<Group mb="xl" gap="md">
|
||||
<TextInput
|
||||
placeholder="Search operator or app..."
|
||||
placeholder="Search operator or message..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
w={220}
|
||||
w={250}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Applications"
|
||||
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']}
|
||||
placeholder="Log Type"
|
||||
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"
|
||||
w={160}
|
||||
clearable
|
||||
value={appFilter}
|
||||
onChange={setAppFilter}
|
||||
value={logType}
|
||||
onChange={(val) => {
|
||||
setLogType(val)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Operators"
|
||||
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']}
|
||||
placeholder="Operator"
|
||||
data={operatorOptions}
|
||||
searchable
|
||||
radius="md"
|
||||
w={160}
|
||||
clearable
|
||||
value={operatorFilter}
|
||||
onChange={setOperatorFilter}
|
||||
w={200}
|
||||
value={operatorId}
|
||||
onChange={(val) => {
|
||||
setOperatorId(val)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}>
|
||||
{filteredTimeline.length === 0 ? (
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
||||
{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>
|
||||
) : filteredTimeline.map((group, groupIndex) => (
|
||||
<Box key={group.date}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={groupIndex > 0 ? "xl" : 0}
|
||||
mb="lg"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{group.date}
|
||||
</Text>
|
||||
|
||||
<Stack gap={0} pl={4}>
|
||||
{group.logs.map((log, logIndex) => {
|
||||
const isLastLog = logIndex === group.logs.length - 1;
|
||||
) : (
|
||||
<>
|
||||
{filteredTimeline.map((group, groupIndex) => (
|
||||
<Box key={group.date}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={groupIndex > 0 ? "xl" : 0}
|
||||
mb="lg"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{group.date}
|
||||
</Text>
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={log.id}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
gap="lg"
|
||||
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
||||
>
|
||||
{/* Left: Time */}
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
w={70}
|
||||
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
||||
>
|
||||
{log.time}
|
||||
</Text>
|
||||
|
||||
{/* Middle: Line & Avatar */}
|
||||
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
||||
{/* Vertical Line */}
|
||||
{!isLastLog && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 24,
|
||||
bottom: -8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 1,
|
||||
backgroundColor: 'rgba(128,128,128,0.2)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar */}
|
||||
<Box style={{ position: 'relative', zIndex: 2 }}>
|
||||
{log.icon ? (
|
||||
<Avatar size={24} radius="xl" color={log.color} variant="light">
|
||||
<log.icon size={14} />
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar size={24} radius="xl" color={log.color}>
|
||||
{log.operator.charAt(0)}
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right: Content */}
|
||||
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
||||
<Text size="sm">
|
||||
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
|
||||
{log.content}
|
||||
</Text>
|
||||
|
||||
{log.message && (
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
mt="sm"
|
||||
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
|
||||
<Stack gap={0} pl={4}>
|
||||
{group.logs.map((log, logIndex) => {
|
||||
const isLastLog = logIndex === group.logs.length - 1;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={log.id}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
gap="lg"
|
||||
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
||||
>
|
||||
{/* Left: Time */}
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
w={70}
|
||||
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{log.message.text}
|
||||
{log.time}
|
||||
</Text>
|
||||
|
||||
{/* Middle: Line & Avatar */}
|
||||
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
||||
{/* Vertical Line */}
|
||||
{!isLastLog && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 24,
|
||||
bottom: -8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 1,
|
||||
backgroundColor: 'rgba(128,128,128,0.2)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar */}
|
||||
<Box style={{ position: 'relative', zIndex: 2 }}>
|
||||
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
|
||||
<Avatar
|
||||
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>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right: Content */}
|
||||
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
||||
<Text size="sm">
|
||||
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
|
||||
{log.content}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{groupIndex < timelineData.length - 1 && (
|
||||
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
||||
</Box>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{groupIndex < filteredTimeline.length - 1 && (
|
||||
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{response?.totalPages > 1 && (
|
||||
<Center mt="xl">
|
||||
<Pagination
|
||||
total={response.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
List,
|
||||
Box,
|
||||
Divider,
|
||||
Pagination,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
@@ -34,40 +35,59 @@ import {
|
||||
} from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
|
||||
export const Route = createFileRoute('/users')({
|
||||
component: UsersPage,
|
||||
})
|
||||
|
||||
const mockUsers = [
|
||||
{ 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' },
|
||||
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
|
||||
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
|
||||
]
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
const r = (role || '').toLowerCase()
|
||||
if (r.includes('super')) return 'red'
|
||||
if (r.includes('admin')) return 'brand-blue'
|
||||
if (r.includes('developer')) return 'violet'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: 'SUPER_ADMIN',
|
||||
count: 2,
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
||||
},
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
count: 12,
|
||||
color: 'brand-blue',
|
||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
||||
},
|
||||
{
|
||||
name: 'QA',
|
||||
count: 5,
|
||||
color: 'orange',
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
||||
},
|
||||
]
|
||||
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
@@ -80,9 +100,9 @@ function UsersPage() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
|
||||
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
|
||||
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
|
||||
<StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
|
||||
<StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
|
||||
<StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
||||
@@ -100,6 +120,11 @@ function UsersPage() {
|
||||
radius="md"
|
||||
w={350}
|
||||
variant="filled"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="gradient"
|
||||
@@ -117,56 +142,72 @@ function UsersPage() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Name & Contact</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>App Access</Table.Th>
|
||||
<Table.Th>Joined Date</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{user.name}</Text>
|
||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<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>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{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.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
|
||||
{user.name.charAt(0)}
|
||||
</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{user.name}</Text>
|
||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={getRoleColor(user.role)}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{response?.totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
total={response.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -179,7 +220,6 @@ function UsersPage() {
|
||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||
<TbShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
|
||||
18
src/lib/logger.ts
Normal file
18
src/lib/logger.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user