18 Commits

Author SHA1 Message Date
65e9ed5ce6 upd: connected api 2026-04-13 17:15:41 +08:00
2cf96135f9 upd: menerapkan log pada semua aksi 2026-04-13 16:42:36 +08:00
14a9e2c687 upd: bug list
Deskripsi:
- tampilan list bug error
- tampilan tambah bug
- connected to database; list and create

No Issues
2026-04-13 15:17:46 +08:00
c0205ce2bf upd: user dan log activity 2026-04-13 14:48:49 +08:00
315ecc565e upd: api monitoring
Deskripsi :
- api deactivate or active desa
- api edit desa

No Issues
2026-04-13 11:21:25 +08:00
8c50768c98 upd: tampilan mode dark and light'; 2026-04-13 11:00:40 +08:00
5cc73d2290 Merge pull request 'upd: api monitoring user' (#6) from amalia/10-apr-26 into main
Reviewed-on: #6
2026-04-10 13:43:01 +08:00
ac3c673500 upd: api monitoring user 2026-04-10 13:41:38 +08:00
e1b9241c35 Merge pull request 'amalia/09-apr-26' (#5) from amalia/09-apr-26 into main
Reviewed-on: #5
2026-04-09 17:34:49 +08:00
cc49a1fcd3 upd: connected api
Deskripsi:
- tambah desa

No Issues
2026-04-09 17:30:55 +08:00
c63b8cd385 upd: connected api monitoring
Deskripsi:
- update version

No Issues
2026-04-09 16:58:02 +08:00
ba74539542 upd: connected api monitoring
Deskripsi:
- overview page

No Issues
2026-04-09 15:21:10 +08:00
3a91bb5b17 upd: connected api monitoring
Deskripsi:
- list log semua desa

No Issues
2026-04-09 14:35:56 +08:00
91ad56348f upd: connected api monitoring
Deskripsi:
- list user
- tampilan page list user

No Issues
2026-04-09 14:27:49 +08:00
4fad913890 upd: menghubungkan dengan api desa+
Deskripsi:
- list desa
- detail desa

No Issues
2026-04-09 12:16:25 +08:00
7b23192121 Merge pull request 'upd: database' (#4) from amalia/06-apr-26 into main
Reviewed-on: #4
2026-04-06 17:25:21 +08:00
e889a97e2a upd: database 2026-04-06 17:24:28 +08:00
12e65b33d3 Merge pull request 'amalia/04-apr-26' (#3) from amalia/04-apr-26 into main
Reviewed-on: #3
2026-04-04 12:11:57 +08:00
27 changed files with 3140 additions and 810 deletions

View File

@@ -6,6 +6,7 @@
"name": "bun-react-template",
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
@@ -21,6 +22,7 @@
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1",
"swr": "^2.4.1",
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
@@ -99,6 +101,8 @@
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -427,6 +431,8 @@
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -705,6 +711,8 @@
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -22,10 +22,12 @@
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
@@ -36,7 +38,8 @@
"react": "^19",
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"swr": "^2.4.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",

View File

@@ -0,0 +1,82 @@
-- CreateEnum
CREATE TYPE "App" AS ENUM ('desa_plus', 'hipmi');
-- CreateEnum
CREATE TYPE "BugSource" AS ENUM ('QC', 'SYSTEM', 'USER');
-- CreateEnum
CREATE TYPE "BugStatus" AS ENUM ('OPEN', 'ON_HOLD', 'IN_PROGRESS', 'RESOLVED', 'RELEASED', 'CLOSED');
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'DEVELOPER';
-- AlterTable
ALTER TABLE "user" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "log" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug" (
"id" TEXT NOT NULL,
"userId" TEXT,
"app" "App" NOT NULL,
"affectedVersion" TEXT NOT NULL,
"device" TEXT NOT NULL,
"os" TEXT NOT NULL,
"status" "BugStatus" NOT NULL,
"source" "BugSource" NOT NULL,
"description" TEXT NOT NULL,
"stackTrace" TEXT,
"fixedVersion" TEXT,
"feedBack" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug_image" (
"id" TEXT NOT NULL,
"bugId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bug_image_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug_log" (
"id" TEXT NOT NULL,
"bugId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "BugStatus" NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bug_log_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "log" ADD CONSTRAINT "log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug" ADD CONSTRAINT "bug_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_image" ADD CONSTRAINT "bug_image_bugId_fkey" FOREIGN KEY ("bugId") REFERENCES "bug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_log" ADD CONSTRAINT "bug_log_bugId_fkey" FOREIGN KEY ("bugId") REFERENCES "bug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_log" ADD CONSTRAINT "bug_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

@@ -12,6 +12,31 @@ enum Role {
USER
ADMIN
SUPER_ADMIN
DEVELOPER
}
enum BugSource{
QC
SYSTEM
USER
}
enum BugStatus{
OPEN
ON_HOLD
IN_PROGRESS
RESOLVED
RELEASED
CLOSED
}
enum LogType{
CREATE
UPDATE
DELETE
LOGIN
LOGOUT
}
model User {
@@ -20,10 +45,15 @@ model User {
email String @unique
password String
role Role @default(USER)
active Boolean @default(true)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
logs Log[]
bugs Bug[]
bugLogs BugLog[]
@@map("user")
}
@@ -40,3 +70,68 @@ model Session {
@@index([token])
@@map("session")
}
model Log {
id String @id @default(uuid())
userId String
type LogType
message String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("log")
}
model Bug {
id String @id @default(uuid())
userId String?
app String?
affectedVersion String
device String
os String
status BugStatus
source BugSource
description String
stackTrace String?
fixedVersion String?
feedBack String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
images BugImage[]
logs BugLog[]
@@map("bug")
}
model BugImage {
id String @id @default(uuid())
bugId String
imageUrl String
createdAt DateTime @default(now())
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
@@map("bug_image")
}
model BugLog {
id String @id @default(uuid())
bugId 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)
@@map("bug_log")
}

View File

@@ -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!',

View File

@@ -1,5 +1,7 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { Notifications } from '@mantine/notifications'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
@@ -61,6 +63,7 @@ export function App() {
<>
<ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>

View File

@@ -11,25 +11,12 @@ import {
import { LineChart, BarChart } from '@mantine/charts'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
const activityData = [
{ date: 'Mar 26', logs: 1200 },
{ date: 'Mar 27', logs: 1900 },
{ date: 'Mar 28', logs: 1540 },
{ date: 'Mar 29', logs: 2400 },
{ date: 'Mar 30', logs: 2100 },
{ date: 'Mar 31', logs: 3200 },
{ date: 'Apr 01', logs: 3800 },
]
interface ChartProps {
data?: any[]
isLoading?: boolean
}
const villageComparisonData = [
{ village: 'Sukatani', activity: 4500 },
{ village: 'Sukamaju', activity: 3800 },
{ village: 'Bojong Gede', activity: 3200 },
{ village: 'Beji', activity: 2800 },
{ village: 'Tapos', activity: 2400 },
]
export function VillageActivityLineChart() {
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
@@ -46,14 +33,14 @@ export function VillageActivityLineChart() {
</Box>
</Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
Growing
{isLoading ? '...' : 'Live'}
</Badge>
</Group>
<Box h={300} mt="lg">
<LineChart
h={300}
data={activityData}
data={data}
dataKey="date"
series={[{ name: 'logs', color: '#2563EB' }]}
curveType="monotone"
@@ -76,7 +63,7 @@ export function VillageActivityLineChart() {
)
}
export function VillageComparisonBarChart() {
export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
@@ -89,7 +76,7 @@ export function VillageComparisonBarChart() {
</ThemeIcon>
<Box>
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text>
<Text size="xs" c="dimmed">Most active village deployments</Text>
</Box>
</Group>
</Group>
@@ -97,7 +84,7 @@ export function VillageComparisonBarChart() {
<Box h={300} mt="lg">
<BarChart
h={300}
data={villageComparisonData}
data={data}
dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]}
withTooltip
@@ -112,7 +99,6 @@ export function VillageComparisonBarChart() {
}
}}
>
{/* Custom SVG Gradient definitions for Premium SaaS look */}
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />

View File

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

View File

@@ -0,0 +1,38 @@
export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS
export const API_URLS = {
getVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
infoVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/info-villages?id=${id}`,
gridVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) =>
`${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
getUsers: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
getLogsAllVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
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`,
}

View File

@@ -1,5 +1,5 @@
import { IconType } from 'react-icons'
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard, TbBuilding } from 'react-icons/tb'
import { TbAlertTriangle, TbBuilding, TbChartBar, TbCreditCard, TbHistory, TbPackage, TbShoppingCart, TbUsers } from 'react-icons/tb'
export interface MenuItem {
value: string
@@ -23,6 +23,7 @@ export const APP_CONFIGS: Record<string, AppConfig> = {
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
{ value: 'users', label: 'Users', icon: TbUsers, to: '/apps/desa-plus/users' },
],
},
'e-commerce': {

View File

@@ -2,147 +2,229 @@ import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/
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,
Textarea
Text,
Textarea,
TextInput,
Title
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
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/')({
component: AppOverviewPage,
})
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
// Form State
const [latestVersion, setLatestVersion] = useState('')
const [minVersion, setMinVersion] = useState('')
const [messageUpdate, setMessageUpdate] = useState('')
const [maintenance, setMaintenance] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Data Fetching
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
const grid = gridRes?.data
const dailyData = dailyRes?.data || []
const comparisonData = comparisonRes?.data || []
// Initialize form when data loads or modal opens
useEffect(() => {
if (grid?.version && versionModalOpened) {
setLatestVersion(grid.version.mobile_latest_version || '')
setMinVersion(grid.version.mobile_minimum_version || '')
setMessageUpdate(grid.version.mobile_message_update || '')
setMaintenance(grid.version.mobile_maintenance === 'true')
}
}, [grid, versionModalOpened])
const handleRefresh = () => {
mutateGrid()
mutateDaily()
mutateComparison()
}
const handleSaveVersion = async () => {
setIsSaving(true)
try {
const response = await fetch(API_URLS.postVersionUpdate(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mobile_latest_version: latestVersion,
mobile_minimum_version: minVersion,
mobile_maintenance: maintenance,
mobile_message_update: messageUpdate,
}),
})
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.',
color: 'teal',
})
mutateGrid()
closeVersionModal()
} else {
notifications.show({
title: 'Update Failed',
message: 'Failed to update version information. Please check your data.',
color: 'red',
})
}
} catch (error) {
notifications.show({
title: 'Network Error',
message: 'Could not connect to the server. Please try again later.',
color: 'red',
})
} finally {
setIsSaving(false)
}
}
return (
<>
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
<Stack gap="md">
<TextInput label="Active Version" defaultValue="v1.2.0" />
<TextInput label="Minimum Version" defaultValue="v1.0.0" />
<Textarea
label="Update Message"
placeholder="Enter release notes or update message..."
<TextInput
label="Active Version"
placeholder="e.g. 2.0.5"
value={latestVersion}
onChange={(e) => setLatestVersion(e.currentTarget.value)}
/>
<TextInput
label="Minimum Version"
placeholder="e.g. 2.0.0"
value={minVersion}
onChange={(e) => setMinVersion(e.currentTarget.value)}
/>
<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." />
<Button fullWidth onClick={closeVersionModal}>Save Changes</Button>
<Switch
label="Maintenance Mode"
description="Enable to put the app in maintenance mode for users."
checked={maintenance}
onChange={(e) => setMaintenance(e.currentTarget.checked)}
/>
<Button fullWidth onClick={handleSaveVersion} loading={isSaving}>Save Changes</Button>
</Stack>
</Modal>
<Stack gap="xl">
{/* 🔝 HEADER SECTION */}
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */}
<Group justify="space-between">
<Stack gap={0}>
<Title order={3}>Overview</Title>
<Text size="sm" c="dimmed">Last updated: Just now</Text>
</Stack>
<Group justify="space-between">
<Stack gap={0}>
<Title order={3}>Overview</Title>
<Text size="sm" c="dimmed">Detailed metrics for {isDesaPlus ? 'Desa+' : appId}</Text>
</Stack>
<Group gap="md">
{/* <Select
placeholder="Date Range"
data={['Today', '7 Days', '30 Days']}
defaultValue="Today"
leftSection={<TbCalendar size={16} />}
radius="md"
w={140}
/> */}
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbRefresh size={20} />
</ActionIcon>
{/* <Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED' }}
radius="md"
leftSection={<TbFilter size={18} />}
>
Add Filter
</Button> */}
{/* <Group gap="md">
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md" onClick={handleRefresh}>
<TbRefresh size={20} />
</ActionIcon>
</Group> */}
</Group>
</Group>
{/* </Paper> */}
{/* 📊 1. SUMMARY CARDS */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SummaryCard
title="Active Version"
value="v1.2.0"
icon={TbVersions}
color="brand-blue"
onClick={openVersionModal}
>
<Group justify="space-between" mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">Min. Version</Text>
<Text size="sm" fw={600}>v1.0.0</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed">Maintenance</Text>
<Badge size="sm" color="gray" variant="light">False</Badge>
</Stack>
</Group>
</SummaryCard>
<SummaryCard
title="Total Activity Today"
value="3,842"
icon={TbActivity}
color="teal"
trend={{ value: '14.2%', positive: true }}
/>
<SummaryCard
title="Total Villages Active"
value="138"
icon={TbBuildingCommunity}
color="indigo"
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
>
<Group justify="space-between" mt="md">
<Text size="xs" c="dimmed">Nonactive Villages</Text>
<Badge size="sm" color="red" variant="light">24</Badge>
</Group>
</SummaryCard>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SummaryCard
title="Active Version"
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
icon={TbVersions}
color="brand-blue"
onClick={openVersionModal}
>
<Group justify="space-between" mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">Min. Version</Text>
<Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '-'}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed">Maintenance</Text>
<Badge size="sm" color={grid?.version?.mobile_maintenance === 'true' ? 'red' : 'gray'} variant="light">
{grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
</Badge>
</Stack>
</Group>
</SummaryCard>
{/* 📈 📊 2 & 3. CHARTS GRID */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart />
<VillageComparisonBarChart />
</SimpleGrid>
<SummaryCard
title="Total Activity Today"
value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() || '0')}
icon={TbActivity}
color="teal"
trend={grid?.activity?.increase ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } : undefined}
/>
{/* 🐞 4. LATEST ERROR REPORTS */}
<ErrorDataTable />
</Stack>
<SummaryCard
title="Total Villages Active"
value={gridLoading ? '...' : (grid?.village?.active || '0')}
icon={TbBuildingCommunity}
color="indigo"
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
>
<Group justify="space-between" mt="md">
<Text size="xs" c="dimmed">Nonactive Villages</Text>
<Badge size="sm" color="red" variant="light">{grid?.village?.inactive || 0}</Badge>
</Group>
</SummaryCard>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
</SimpleGrid>
<ErrorDataTable />
</Stack>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import useSWR from 'swr'
import {
Badge,
Container,
Group,
Stack,
Text,
@@ -8,116 +9,247 @@ import {
Paper,
Table,
TextInput,
Select,
ActionIcon,
Tooltip,
Avatar,
Code,
Button
Button,
Box,
Pagination,
ThemeIcon,
ScrollArea,
Container,
} from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
import {
TbSearch,
TbDownload,
TbX,
TbHistory,
TbCalendar,
TbUser,
TbHome2
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/logs')({
component: AppLogsPage,
})
const mockLogs = [
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
]
interface LogEntry {
id: string
createdAt: string
action: string
desc: string
username: string
village: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus'
const isMobile = useMediaQuery('(max-width: 768px)')
const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
const logs: LogEntry[] = response?.data?.log || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const getActionColor = (action: string) => {
const a = action.toUpperCase()
if (a === 'LOGIN') return 'blue'
if (a === 'LOGOUT') return 'gray'
if (a === 'CREATE') return 'teal'
if (a === 'UPDATE') return 'orange'
if (a === 'DELETE') return 'red'
return 'brand-blue'
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbHistory size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">Activity Logs</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title>
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text>
</Stack>
<Group gap="xs">
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button>
</Group>
</Group>
<Stack gap="xl" py="md">
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #7C3AED' }}>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="violet" size="lg" radius="md">
<TbHistory size={22} />
</ThemeIcon>
<Title order={3}>Activity Logs</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
</Text>
</Stack>
{/* <Button
variant="light"
color="gray"
leftSection={<TbDownload size={18} />}
radius="md"
size="md"
>
Export
</Button> */}
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput
placeholder="Search activity, village, or operator..."
leftSection={<TbSearch size={16} />}
placeholder="Search action or village..."
leftSection={<TbSearch size={18} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ maxWidth: 500 }}
ml={40}
/>
<Select
placeholder="All Service Types"
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
leftSection={<TbFilter size={16} />}
</Stack>
</Paper>
{isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Fetching activity logs...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load logs from API.</Text>
</Paper>
) : logs.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbHistory size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No activity found for this search.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr>
<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 : '15%' }}>Action</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '40%' }}>Description</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log) => (
<Table.Tr key={log.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon variant="transparent" color="gray" size="sm">
<TbCalendar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text size="xs" fw={700}>
{log.createdAt.split(' ').slice(1).join(' ')}
</Text>
<Text size="xs" c="dimmed">
{log.createdAt.split(' ')[0]}
</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap">
<Avatar size="xs" radius="xl" color="brand-blue" variant="light">
{log.username.charAt(0)}
</Avatar>
<Text size="xs" fw={700} truncate="end">{log.username}</Text>
</Group>
<Group gap={8} wrap="nowrap">
<TbHome2 size={12} color="gray" />
<Text size="xs" c="dimmed" truncate="end">{log.village}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="dot"
color={getActionColor(log.action)}
radius="sm"
size="xs"
styles={{
root: { fontWeight: 800 },
label: { textOverflow: 'clip', overflow: 'visible' }
}}
>
{log.action}
</Badge>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="rgba(37, 99, 235, 0.05)" fw={600} style={{ fontSize: '11px', display: 'block', whiteSpace: 'normal' }}>
{log.desc}
</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md"
clearable
withEdges={false}
siblings={1}
boundaries={1}
/>
</Group>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Village / Instance</Table.Th>
<Table.Th>Activity Name</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockLogs.map((log) => (
<Table.Tr key={log.id}>
<Table.Td>
<Badge
variant="light"
color={
log.type === 'DOCUMENT' ? 'blue' :
log.type === 'FINANCE' ? 'teal' :
log.type === 'SYNC' ? 'orange' : 'gray'
}
size="xs"
>
{log.type}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{log.village}</Text>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="transparent" fw={800} style={{ fontSize: '11px' }}>{log.activity}</Code>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Avatar size="xs" radius="xl" color="brand-blue">{log.operator[0]}</Avatar>
<Text size="xs" fw={500}>{log.operator}</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{log.time}</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
variant="dot"
color={log.status === 'SUCCESS' ? 'teal' : 'orange'}
>
{log.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
)}
</Stack>
)
}

View File

@@ -0,0 +1,782 @@
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Container,
Divider,
Group,
Modal,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Switch,
} from '@mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
TbBriefcase,
TbCircleCheck,
TbCircleX,
TbEdit,
TbHome2,
TbId,
TbMail,
TbPhone,
TbPlus,
TbSearch,
TbUsers,
TbX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({
component: UsersIndexPage,
})
interface APIUser {
id: string
name: string
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())
function UsersIndexPage() {
const { appId } = useParams({ from: '/apps/$appId/users/' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const users: APIUser[] = response?.data?.user || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
// --- 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('')
setPage(1)
}
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 isMobile = useMediaQuery('(max-width: 768px)')
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbUsers size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">User Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl" py="md">
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #2563EB' }}>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbUsers size={22} />
</ThemeIcon>
<Title order={3}>User Management</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
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} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ maxWidth: 500 }}
ml={40}
/>
</Stack>
</Paper>
{isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Loading user data...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load data from API.</Text>
</Paper>
) : users.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbUsers size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No users match your criteria.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="md"
horizontalSpacing="md"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<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 : '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)' }} onClick={()=>{handleEditOpen(user)}}>
<Table.Td>
<Group gap="md" wrap="nowrap">
<Avatar
size="lg"
radius="md"
variant="light"
color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
>
{user.name.charAt(0)}
</Avatar>
<Stack gap={2} style={{ overflow: 'hidden' }}>
<Text fw={700} size="sm" truncate="end" style={{ color: 'var(--mantine-color-white)' }}>{user.name}</Text>
<Group gap={4} wrap="nowrap">
<TbId size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed" style={{ letterSpacing: '0.5px' }} truncate="end">{user.nik}</Text>
</Group>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbMail size={14} />
</ThemeIcon>
<Text size="xs" fw={500} truncate="end">{user.email}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbPhone size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.phone}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="light" color="blue" radius="sm">
<TbHome2 size={12} />
</ThemeIcon>
<Text size="xs" fw={700} truncate="end">{user.village}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbBriefcase size={12} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.group} · {user.position || 'Staff'}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="filled"
color={getRoleColor(user.role)}
radius="md"
size="sm"
fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap="xs" wrap="nowrap">
{user.isActive ? (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', boxShadow: '0 0 8px #10b981' }} />
) : (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
)}
<Text size="xs" fw={800} c={user.isActive ? 'teal.4' : 'red.5'}>
{user.isActive ? 'ACTIVE' : 'INACTIVE'}
</Text>
</Group>
{user.isWithoutOTP && (
<Badge variant="light" color="orange" size="xs" radius="sm">
NO OTP
</Badge>
)}
</Stack>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md"
withEdges={false}
siblings={1}
boundaries={1}
/>
</Group>
)}
</Stack>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/apps/$appId/users')({
component: UsersLayout,
})
function UsersLayout() {
return <Outlet />
}

View File

@@ -1,18 +1,22 @@
import { AreaChart } from '@mantine/charts'
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
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 {
@@ -21,7 +25,6 @@ import {
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbCircleCheck,
TbEdit,
TbHome2,
TbLayoutKanban,
@@ -32,6 +35,10 @@ import {
TbUsersGroup,
TbWifi
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage,
@@ -39,165 +46,33 @@ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
// ── Mock Data ────────────────────────────────────────────────────────────────
const mockVillages: Record<string, any> = {
'sukatani': {
id: 'sukatani',
name: 'Sukatani',
kecamatan: 'Tapos',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
kodePos: '16455',
perbekel: 'H. Suryana, S.Sos',
createdAt: '2024-03-12',
createdBy: 'Admin Pusat',
updatedAt: '2024-04-01',
status: 'fully integrated',
lastSync: '2 menit lalu',
stats: { users: 1240, groups: 34, divisions: 8, activities: 4520 },
},
'sukamaju': {
id: 'sukamaju',
name: 'Sukamaju',
kecamatan: 'Cilodong',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
kodePos: '16413',
perbekel: 'Drs. H. Mujiono',
createdAt: '2024-04-01',
createdBy: 'Amel',
updatedAt: '2024-04-10',
status: 'sync active',
lastSync: '15 menit lalu',
stats: { users: 980, groups: 28, divisions: 6, activities: 3180 },
},
'cikini': {
id: 'cikini',
name: 'Cikini',
kecamatan: 'Menteng',
kabupaten: 'Jakarta Pusat',
provinsi: 'DKI Jakarta',
kodePos: '10330',
perbekel: 'Ir. Budi Santoso',
createdAt: '2024-05-20',
createdBy: 'Jane Smith',
updatedAt: '2024-05-25',
status: 'sync pending',
lastSync: 'Belum pernah sync',
stats: { users: 420, groups: 12, divisions: 3, activities: 640 },
},
'bojong-gede': {
id: 'bojong-gede',
name: 'Bojong Gede',
kecamatan: 'Bojong Gede',
kabupaten: 'Kabupaten Bogor',
provinsi: 'Jawa Barat',
kodePos: '16920',
perbekel: 'H. Rahmat Hidayat, M.Si',
createdAt: '2024-02-15',
createdBy: 'Rahmat',
updatedAt: '2024-04-02',
status: 'fully integrated',
lastSync: '1 jam lalu',
stats: { users: 1890, groups: 51, divisions: 12, activities: 7340 },
},
'ciputat': {
id: 'ciputat',
name: 'Ciputat',
kecamatan: 'Ciputat',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
kodePos: '15411',
perbekel: 'Drs. Ahmad Fauzi',
createdAt: '2024-06-10',
createdBy: 'Admin Pusat',
updatedAt: '2024-06-15',
status: 'sync active',
lastSync: '30 menit lalu',
stats: { users: 1120, groups: 30, divisions: 7, activities: 3860 },
},
'serpong': {
id: 'serpong',
name: 'Serpong',
kecamatan: 'Serpong',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
kodePos: '15310',
perbekel: 'H. Bambang Wijaya',
createdAt: '2024-07-05',
createdBy: 'Amel',
updatedAt: '2024-07-10',
status: 'sync pending',
lastSync: 'Belum tersinkronisasi',
stats: { users: 280, groups: 8, divisions: 2, activities: 310 },
},
}
// Mock data removed as it is replaced by API calls
// ── Chart Data Generators ─────────────────────────────────────────────────────
function generateDailyData() {
const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']
const today = new Date()
return Array.from({ length: 14 }, (_, i) => {
const d = new Date(today)
d.setDate(today.getDate() - (13 - i))
const dayName = days[d.getDay() === 0 ? 6 : d.getDay() - 1]
const dateStr = `${dayName} ${d.getDate()}/${d.getMonth() + 1}`
return {
label: dateStr,
aktivitas: Math.floor(Math.random() * 300 + 60),
}
})
}
function generateMonthlyData() {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
return months.map((m) => ({
label: m,
aktivitas: Math.floor(Math.random() * 2000 + 800),
}))
}
function generateYearlyData() {
return ['2021', '2022', '2023', '2024'].map((y) => ({
label: y,
aktivitas: Math.floor(Math.random() * 15000 + 5000),
}))
}
// Remove chart data generators as they are replaced by API calls
// ── Helpers ───────────────────────────────────────────────────────────────────
const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi Penuh' },
'sync active': { color: 'blue', label: 'Sync Aktif' },
'sync pending': { color: 'orange', label: 'Menunggu Sync' },
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
// ── Activity Chart ────────────────────────────────────────────────────────────
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart() {
const [period, setPeriod] = useState<ChartPeriod>('monthly')
function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily')
const dataMap: Record<ChartPeriod, any[]> = {
daily: generateDailyData(),
monthly: generateMonthlyData(),
yearly: generateYearlyData(),
}
const { data: response, isLoading } = useSWR(
API_URLS.graphLogVillages(villageId, period),
fetcher
)
const labels: Record<ChartPeriod, string> = {
daily: 'Harian (14 hari terakhir)',
monthly: 'Bulanan (tahun ini)',
yearly: 'Tahunan',
daily: 'Daily (last 14 days)',
monthly: 'Monthly (this year)',
yearly: 'Yearly',
}
const data = dataMap[period]
const data = response?.data || []
return (
<Paper withBorder radius="xl" p="lg">
@@ -207,7 +82,7 @@ function ActivityChart() {
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Log Aktivitas Desa</Text>
<Text fw={700} size="sm">Village Activity Log</Text>
<Text size="xs" c="dimmed">{labels[period]}</Text>
</Stack>
</Group>
@@ -218,46 +93,52 @@ function ActivityChart() {
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Harian' },
{ value: 'monthly', label: 'Bulanan' },
{ value: 'yearly', label: 'Tahunan' },
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
</Group>
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]}
curveType="monotone"
withTooltip
withDots
tickLine="none"
gridAxis="x"
tooltipAnimationDuration={150}
fillOpacity={1}
areaProps={{
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}}
dotProps={{
r: 4,
strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}}
>
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
{isLoading ? (
<Stack h={280} align="center" justify="center">
<Text size="sm" c="dimmed">Loading chart data...</Text>
</Stack>
) : (
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
curveType="monotone"
withTooltip
withDots
tickLine="none"
gridAxis="x"
tooltipAnimationDuration={150}
fillOpacity={1}
areaProps={{
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}}
dotProps={{
r: 4,
strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}}
>
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
)}
</Paper>
)
}
@@ -267,26 +148,156 @@ function ActivityChart() {
function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate()
const village = mockVillages[villageId]
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) {
return (
<Stack align="center" py="xl" gap="md">
<Text c="dimmed">Loading village data...</Text>
</Stack>
)
}
if (!village) {
return (
<Stack align="center" py="xl" gap="md">
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
<Title order={4}>Desa tidak ditemukan</Title>
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text>
<Title order={4}>Village not found</Title>
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Kembali ke Daftar
Back to List
</Button>
</Stack>
)
}
const cfg = statusConfig[village.status as keyof typeof statusConfig]
const { stats } = village
return (
<Stack gap="xl">
@@ -300,28 +311,29 @@ function VillageDetailPage() {
radius="md"
onClick={goBack}
>
Daftar Desa
Village List
</Button>
{/* Action Buttons */}
<Group gap="sm">
<Button
variant="filled"
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'}
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)}
color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={openConfirmModal}
radius="md"
loading={isUpdating}
>
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
{village.isActive ? 'Deactivate' : 'Active'}
</Button>
<Button
variant="light"
color="blue"
leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)}
onClick={openEdit}
radius="md"
>
Edit Desa
Edit
</Button>
</Group>
</Group>
@@ -356,18 +368,18 @@ function VillageDetailPage() {
<Group gap={6}>
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
Location data not available
</Text>
</Group>
<Group gap={6}>
<TbUser size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Perbekel: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
Village Head: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
</Text>
</Group>
<Group gap="xs" mt={2}>
{/* <Group gap="xs" mt={2}>
<Badge
variant="outline"
radius="sm"
@@ -377,24 +389,16 @@ function VillageDetailPage() {
>
{cfg.label}
</Badge>
<Badge
variant="outline"
radius="sm"
size="sm"
style={{ color: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,255,255,0.25)' }}
>
Kode Pos: {village.kodePos}
</Badge>
</Group>
</Group> */}
</Stack>
</Group>
{/* Last Sync block */}
<Stack gap={4} align="flex-end">
<Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text>
{/* <Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text> */}
<Group gap={6}>
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.lastSync}</Text>
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
</Group>
</Stack>
</Group>
@@ -403,19 +407,25 @@ function VillageDetailPage() {
{/* ── Stats Cards ── */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' },
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' },
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' },
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' },
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
].map((s) => (
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs">
<s.icon size={18} />
</ThemeIcon>
<Group justify="space-between" align="flex-start" mb="xs">
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
<s.icon size={18} />
</ThemeIcon>
<Stack gap={0} align="flex-end">
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
</Stack>
</Group>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{s.label}
</Text>
<Text size="xl" fw={800} mt={2}>{s.value}</Text>
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
</Card>
))}
</SimpleGrid>
@@ -430,7 +440,7 @@ function VillageDetailPage() {
}}
>
{/* Left (3/4): Activity Chart */}
<ActivityChart />
<ActivityChart villageId={villageId} />
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
@@ -438,13 +448,13 @@ function VillageDetailPage() {
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} />
</ThemeIcon>
<Text fw={700} size="sm">Informasi Sistem</Text>
<Text fw={700} size="sm">System Information</Text>
</Group>
<Stack gap={0}>
{[
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) },
{ label: 'Dibuat Oleh', value: village.createdBy },
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) },
{ label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' },
].map((item, idx, arr) => (
<Group
key={item.label}
@@ -463,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>
)
}

View File

@@ -1,134 +1,82 @@
import { useState } from 'react'
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
Group,
Modal,
Pagination,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon,
Textarea,
TextInput,
Tooltip,
SimpleGrid,
Avatar,
Box,
SegmentedControl,
Card,
Divider,
ThemeIcon,
Title,
Tooltip
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { useDisclosure } from '@mantine/hooks'
import {
TbPlus,
TbSearch,
TbArrowRight,
TbBuildingCommunity,
TbCalendar,
TbChevronRight,
TbHome2,
TbLayoutGrid,
TbList,
TbMapPin,
TbCalendar,
TbPlus,
TbSearch,
TbUser,
TbHome2,
TbArrowRight,
TbChevronRight,
TbX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/villages/')({
component: AppVillagesIndexPage,
})
const mockVillages = [
{
id: 'sukatani',
name: 'Sukatani',
kecamatan: 'Tapos',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
perbekel: 'H. Suryana, S.Sos',
createdAt: '2024-03-12',
createdBy: 'Admin Pusat',
status: 'fully integrated',
population: 4500,
},
{
id: 'sukamaju',
name: 'Sukamaju',
kecamatan: 'Cilodong',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
perbekel: 'Drs. H. Mujiono',
createdAt: '2024-04-01',
createdBy: 'Amel',
status: 'sync active',
population: 3800,
},
{
id: 'cikini',
name: 'Cikini',
kecamatan: 'Menteng',
kabupaten: 'Jakarta Pusat',
provinsi: 'DKI Jakarta',
perbekel: 'Ir. Budi Santoso',
createdAt: '2024-05-20',
createdBy: 'Jane Smith',
status: 'sync pending',
population: 2100,
},
{
id: 'bojong-gede',
name: 'Bojong Gede',
kecamatan: 'Bojong Gede',
kabupaten: 'Kabupaten Bogor',
provinsi: 'Jawa Barat',
perbekel: 'H. Rahmat Hidayat, M.Si',
createdAt: '2024-02-15',
createdBy: 'Rahmat',
status: 'fully integrated',
population: 6700,
},
{
id: 'ciputat',
name: 'Ciputat',
kecamatan: 'Ciputat',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
perbekel: 'Drs. Ahmad Fauzi',
createdAt: '2024-06-10',
createdBy: 'Admin Pusat',
status: 'sync active',
population: 5200,
},
{
id: 'serpong',
name: 'Serpong',
kecamatan: 'Serpong',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
perbekel: 'H. Bambang Wijaya',
createdAt: '2024-07-05',
createdBy: 'Amel',
status: 'sync pending',
population: 8900,
},
]
interface APIVillage {
id: string
name: string
isActive: boolean
createdAt: string
perbekel: string | null
}
const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi' },
'sync active': { color: 'blue', label: 'Sync Aktif' },
'sync pending': { color: 'orange', label: 'Sync Pending' },
'active': { color: 'teal', label: 'Active' },
'inactive': { color: 'orange', label: 'Inactive' },
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
} catch (e) {
return dateStr
}
}
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Card
withBorder
@@ -158,12 +106,12 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<Group gap={4} mb="md">
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten}
No location details available
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{village.provinsi}
-
</Text>
<Divider my="sm" />
@@ -171,19 +119,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<Stack gap={6}>
<Group gap="xs">
<TbUser size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Perbekel:</Text>
<Text size="xs" fw={600}>{village.perbekel}</Text>
<Text size="xs" c="dimmed">Village Head:</Text>
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Group>
<Group gap="xs">
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Dibuat:</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
<Text size="xs" c="dimmed">Created:</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Group>
<Group gap="xs">
{/* <Group gap="xs">
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
<Text size="xs" c="dimmed">Oleh:</Text>
<Text size="xs" c="dimmed">By:</Text>
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Group> */}
</Stack>
<Button
@@ -196,14 +144,15 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
rightSection={<TbArrowRight size={14} />}
styles={{ root: { fontSize: 12 } }}
>
Lihat Detail
View Details
</Button>
</Card>
)
}
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Paper
withBorder
@@ -233,7 +182,7 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<Group gap={6}>
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
No location details available
</Text>
</Group>
</Stack>
@@ -241,20 +190,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<Group gap="xl" visibleFrom="md">
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Perbekel</Text>
<Text size="xs" fw={600}>{village.perbekel}</Text>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Village Head</Text>
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Stack>
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Stack>
<Stack gap={0} align="center">
{/* <Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
<Group gap={4}>
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Stack>
</Stack> */}
</Group>
<ActionIcon variant="light" color="brand-blue" radius="md">
@@ -269,21 +218,111 @@ function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// Form State
const [isSubmitting, setIsSubmitting] = useState(false)
const [form, setForm] = useState({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
const filtered = mockVillages.filter((v) =>
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
)
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const villages: APIVillage[] = response?.data || []
const handleVillageClick = (villageId: string) => {
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
}
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const handleCreateVillage = async () => {
const requiredFields = ['name', 'desc', 'username', 'phone', 'nik', 'email', 'gender'] as const
const isFormValid = requiredFields.every(field => !!form[field])
if (!isFormValid) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required to register a new village.',
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
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.',
color: 'teal'
})
mutate() // Refresh list
closeCreateModal()
setForm({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
} else {
notifications.show({
title: 'Error',
message: 'Failed to create village. Please try again.',
color: 'red'
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to reach API server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
@@ -298,11 +337,105 @@ function AppVillagesIndexPage() {
return (
<Stack gap="xl">
<Modal
opened={createModalOpened}
onClose={closeCreateModal}
title={<Text fw={700} size="lg">Register New Village</Text>}
radius="xl"
size="lg"
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Village Data
</Text>
<Stack gap="sm">
<TextInput
label="Village Name"
placeholder="e.g. Darmasaba"
required
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Short description about the village..."
minRows={3}
required
value={form.desc}
onChange={(e) => setForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
</Stack>
</Box>
<Divider label="Village Head Information" labelPosition="center" my="sm" />
<Box>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Head Name (Username)"
placeholder="Full name of village head"
required
value={form.username}
onChange={(e) => setForm(prev => ({ ...prev, username: e.currentTarget.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(prev => ({ ...prev, nik: e.currentTarget.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email"
placeholder="Email address"
required
value={form.email}
onChange={(e) => setForm(prev => ({ ...prev, email: e.currentTarget.value }))}
/>
<TextInput
label="Phone"
placeholder="Active WhatsApp number"
required
value={form.phone}
onChange={(e) => setForm(prev => ({ ...prev, phone: e.currentTarget.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={['Male', 'Female']}
mt="sm"
required
value={form.gender}
onChange={(val) => setForm(prev => ({ ...prev, gender: val || '' }))}
/>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateVillage}
>
Create Village
</Button>
</Stack>
</Modal>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={3}>Daftar Desa</Title>
<Title order={3}>Village List</Title>
<Text size="sm" c="dimmed">
{filtered.length} desa terdaftar dalam platform <strong>Desa+</strong>
{isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`}
</Text>
</Stack>
<Button
@@ -310,17 +443,25 @@ function AppVillagesIndexPage() {
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openCreateModal}
>
Tambah Desa
Create New Village
</Button>
</Group>
<Group justify="space-between">
<TextInput
placeholder="Cari desa, kecamatan, kabupaten..."
placeholder="Search village, district, city..."
leftSection={<TbSearch size={16} />}
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={14} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ flex: 1, maxWidth: 400 }}
/>
@@ -335,14 +476,27 @@ function AppVillagesIndexPage() {
/>
</Group>
{filtered.length === 0 ? (
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{[1, 2, 3].map((i) => (
<Card key={i} withBorder radius="xl" padding="lg" style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text c="dimmed">Loading...</Text>
</Card>
))}
</SimpleGrid>
) : error ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="red" opacity={0.4} />
<Text c="red" mt="md">Failed to load data from API.</Text>
</Paper>
) : villages.length === 0 ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">Tidak ada desa yang cocok dengan pencarian.</Text>
<Text c="dimmed" mt="md">No villages match your search.</Text>
</Paper>
) : viewMode === 'grid' ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{filtered.map((village) => (
{villages.map((village) => (
<VillageGridCard
key={village.id}
village={village}
@@ -352,7 +506,7 @@ function AppVillagesIndexPage() {
</SimpleGrid>
) : (
<Stack gap="sm">
{filtered.map((village) => (
{villages.map((village) => (
<VillageListRow
key={village.id}
village={village}
@@ -361,6 +515,18 @@ function AppVillagesIndexPage() {
))}
</Stack>
)}
{!isLoading && !error && response?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.totalPage}
radius="md"
withEdges={false}
/>
</Group>
)}
</Stack>
)
}

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 {
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>

View File

@@ -60,12 +60,12 @@ function LoginPage() {
<Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br />
atau: <strong>user@example.com</strong> / <strong>user123</strong>
or: <strong>user@example.com</strong> / <strong>user123</strong>
</Text>
{(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
{login.isError ? login.error.message : 'Google login failed, please try again.'}
</Alert>
)}
@@ -96,7 +96,7 @@ function LoginPage() {
Sign in
</Button>
<Divider label="atau" labelPosition="center" />
<Divider label="or" labelPosition="center" />
<Button
component="a"
@@ -105,7 +105,7 @@ function LoginPage() {
variant="default"
leftSection={<FcGoogle size={18} />}
>
Login dengan Google
Login with Google
</Button>
</Stack>
</form>

View File

@@ -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>

View File

@@ -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
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
}
}