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", "name": "bun-react-template",
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
@@ -21,6 +22,7 @@
"react-dom": "^19", "react-dom": "^19",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"swr": "^2.4.1",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@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/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=="], "@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=="], "@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=="], "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=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "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=="], "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=="], "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=="], "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": { "dependencies": {
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6", "@prisma/client": "6",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
@@ -36,7 +38,8 @@
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.8.1" "recharts": "^3.8.1",
"swr": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@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 USER
ADMIN ADMIN
SUPER_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 { model User {
@@ -20,10 +45,15 @@ model User {
email String @unique email String @unique
password String password String
role Role @default(USER) role Role @default(USER)
active Boolean @default(true)
image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
logs Log[]
bugs Bug[]
bugLogs BugLog[]
@@map("user") @@map("user")
} }
@@ -40,3 +70,68 @@ model Session {
@@index([token]) @@index([token])
@@map("session") @@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 { Elysia } from 'elysia'
import { prisma } from './lib/db' import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger'
export function createApp() { export function createApp() {
return new Elysia() return new Elysia()
@@ -43,13 +44,20 @@ export function createApp() {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
}) })
.post('/api/auth/logout', async ({ request, set }) => { .post('/api/auth/logout', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) await prisma.session.deleteMany({ where: { token } }) if (token) {
const sessionObj = await prisma.session.findUnique({ where: { token } })
if (sessionObj) {
await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully')
await prisma.session.deleteMany({ where: { token } })
}
}
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
return { ok: true } return { ok: true }
}) })
@@ -139,6 +147,8 @@ export function createApp() {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
await createSystemLog(user.id, 'LOGIN', 'Logged in via Google')
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
}) })
@@ -153,8 +163,8 @@ export function createApp() {
.get('/api/apps', () => [ .get('/api/apps', () => [
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
{ id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, // { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
{ id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' }, // { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
]) ])
.get('/api/apps/:appId', ({ params: { appId } }) => { .get('/api/apps/:appId', ({ params: { appId } }) => {
@@ -164,6 +174,214 @@ export function createApp() {
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' } return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
}) })
.get('/api/logs', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = (query.search as string) || ''
const type = query.type as any
const userId = query.userId as string
const where: any = {}
if (search) {
where.OR = [
{ message: { contains: search, mode: 'insensitive' } },
{ user: { name: { contains: search, mode: 'insensitive' } } }
]
}
if (type && type !== 'all') {
where.type = type
}
if (userId && userId !== 'all') {
where.userId = userId
}
const [logs, total] = await Promise.all([
prisma.log.findMany({
where,
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.log.count({ where })
])
return {
data: logs,
totalPages: Math.ceil(total / limit),
totalItems: total
}
})
.post('/api/logs', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined
if (token) {
const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
userId = session.userId
}
}
const body = (await request.json()) as { type: string, message: string }
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || ''
await createSystemLog(actingUserId, body.type as any, body.message)
return { ok: true }
})
.get('/api/operators', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = (query.search as string) || ''
const where: any = {}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }
]
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
orderBy: { name: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where })
])
return {
data: users,
totalPages: Math.ceil(total / limit),
totalItems: total
}
})
.get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
prisma.user.count(),
prisma.session.count({
where: { expiresAt: { gte: new Date() } },
}),
prisma.user.groupBy({
by: ['role'],
_count: true
})
])
return {
totalStaff,
activeNow,
rolesCount: rolesGroup.length
}
})
.get('/api/logs/operators', async () => {
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
orderBy: { name: 'asc' }
})
})
.get('/api/bugs', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = (query.search as string) || ''
const app = query.app as any
const status = query.status as any
const where: any = {}
if (search) {
where.OR = [
{ description: { contains: search, mode: 'insensitive' } },
{ device: { contains: search, mode: 'insensitive' } },
{ os: { contains: search, mode: 'insensitive' } },
{ affectedVersion: { contains: search, mode: 'insensitive' } },
]
}
if (app && app !== 'all') {
where.app = app
}
if (status && status !== 'all') {
where.status = status
}
const [bugs, total] = await Promise.all([
prisma.bug.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, image: true } },
images: true,
logs: {
include: { user: { select: { id: true, name: true, image: true } } },
orderBy: { createdAt: 'desc' },
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.bug.count({ where }),
])
return {
data: bugs,
totalPages: Math.ceil(total / limit),
totalItems: total,
}
})
.post('/api/bugs', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined
if (token) {
const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
userId = session.userId
}
}
const body = (await request.json()) as any
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
const actingUserId = userId || defaultAdmin?.id || ''
const bug = await prisma.bug.create({
data: {
app: body.app,
affectedVersion: body.affectedVersion,
device: body.device,
os: body.os,
status: body.status || 'OPEN',
source: body.source || 'USER',
description: body.description,
stackTrace: body.stackTrace,
userId: userId,
images: body.imageUrl ? {
create: {
imageUrl: body.imageUrl
}
} : undefined,
logs: {
create: {
userId: actingUserId,
status: body.status || 'OPEN',
description: 'Bug reported initially.',
},
},
},
})
return bug
})
// ─── Example API ─────────────────────────────────── // ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({ .get('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',

View File

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

View File

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

View File

@@ -29,7 +29,8 @@ import {
TbSun, TbSun,
TbMoon, TbMoon,
TbUser, TbUser,
TbHistory TbHistory,
TbBug
} from 'react-icons/tb' } from 'react-icons/tb'
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -52,6 +53,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Log Activity', icon: TbHistory, to: '/logs' }, { label: 'Log Activity', icon: TbHistory, to: '/logs' },
{ label: 'Bug Reports', icon: TbBug, to: '/bug-reports' },
{ label: 'Users', icon: TbUser, to: '/users' }, { label: 'Users', icon: TbUser, to: '/users' },
] ]

View File

@@ -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 { 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 { export interface MenuItem {
value: string 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: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' }, { value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' }, { value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
{ value: 'users', label: 'Users', icon: TbUsers, to: '/apps/desa-plus/users' },
], ],
}, },
'e-commerce': { 'e-commerce': {

View File

@@ -2,147 +2,229 @@ import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { SummaryCard } from '@/frontend/components/SummaryCard'
import { import {
ActionIcon, Badge,
Button,
Group, Group,
Modal,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text,
Title,
Modal,
Button,
TextInput,
Switch, Switch,
Badge, Text,
Textarea Textarea,
TextInput,
Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { import {
TbActivity, TbActivity,
TbAlertTriangle, TbAlertTriangle,
TbBuildingCommunity, TbBuildingCommunity,
TbRefresh,
TbVersions TbVersions
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/')({ export const Route = createFileRoute('/apps/$appId/')({
component: AppOverviewPage, component: AppOverviewPage,
}) })
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppOverviewPage() { function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' }) const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate() const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false) 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 ( return (
<> <>
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md"> <Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
<Stack gap="md"> <Stack gap="md">
<TextInput label="Active Version" defaultValue="v1.2.0" /> <TextInput
<TextInput label="Minimum Version" defaultValue="v1.0.0" /> label="Active Version"
<Textarea placeholder="e.g. 2.0.5"
label="Update Message" value={latestVersion}
placeholder="Enter release notes or update message..." 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} minRows={3}
autosize autosize
/> />
<Switch label="Maintenance Mode" description="Enable to put the app in maintenance mode for users." /> <Switch
<Button fullWidth onClick={closeVersionModal}>Save Changes</Button> 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> </Stack>
</Modal> </Modal>
<Stack gap="xl"> <Stack gap="xl">
{/* 🔝 HEADER SECTION */} <Group justify="space-between">
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */} <Stack gap={0}>
<Group justify="space-between"> <Title order={3}>Overview</Title>
<Stack gap={0}> <Text size="sm" c="dimmed">Detailed metrics for {isDesaPlus ? 'Desa+' : appId}</Text>
<Title order={3}>Overview</Title> </Stack>
<Text size="sm" c="dimmed">Last updated: Just now</Text>
</Stack>
<Group gap="md"> {/* <Group gap="md">
{/* <Select <ActionIcon variant="light" color="brand-blue" size="lg" radius="md" onClick={handleRefresh}>
placeholder="Date Range" <TbRefresh size={20} />
data={['Today', '7 Days', '30 Days']} </ActionIcon>
defaultValue="Today" </Group> */}
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> </Group>
</Group>
{/* </Paper> */}
{/* 📊 1. SUMMARY CARDS */} <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg"> <SummaryCard
<SummaryCard title="Active Version"
title="Active Version" value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
value="v1.2.0" icon={TbVersions}
icon={TbVersions} color="brand-blue"
color="brand-blue" onClick={openVersionModal}
onClick={openVersionModal} >
> <Group justify="space-between" mt="md">
<Group justify="space-between" mt="md"> <Stack gap={0}>
<Stack gap={0}> <Text size="xs" c="dimmed">Min. Version</Text>
<Text size="xs" c="dimmed">Min. Version</Text> <Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '-'}</Text>
<Text size="sm" fw={600}>v1.0.0</Text> </Stack>
</Stack> <Stack gap={0} align="flex-end">
<Stack gap={0} align="flex-end"> <Text size="xs" c="dimmed">Maintenance</Text>
<Text size="xs" c="dimmed">Maintenance</Text> <Badge size="sm" color={grid?.version?.mobile_maintenance === 'true' ? 'red' : 'gray'} variant="light">
<Badge size="sm" color="gray" variant="light">False</Badge> {grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
</Stack> </Badge>
</Group> </Stack>
</SummaryCard> </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>
{/* 📈 📊 2 & 3. CHARTS GRID */} <SummaryCard
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg"> title="Total Activity Today"
<VillageActivityLineChart /> value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() || '0')}
<VillageComparisonBarChart /> icon={TbActivity}
</SimpleGrid> color="teal"
trend={grid?.activity?.increase ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } : undefined}
/>
{/* 🐞 4. LATEST ERROR REPORTS */} <SummaryCard
<ErrorDataTable /> title="Total Villages Active"
</Stack> 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 { import {
Badge, Badge,
Container,
Group, Group,
Stack, Stack,
Text, Text,
@@ -8,116 +9,247 @@ import {
Paper, Paper,
Table, Table,
TextInput, TextInput,
Select,
ActionIcon, ActionIcon,
Tooltip,
Avatar, Avatar,
Code, Code,
Button Button,
Box,
Pagination,
ThemeIcon,
ScrollArea,
Container,
} from '@mantine/core' } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router' 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')({ export const Route = createFileRoute('/apps/$appId/logs')({
component: AppLogsPage, component: AppLogsPage,
}) })
const mockLogs = [ interface LogEntry {
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' }, id: string
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' }, createdAt: string
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' }, action: string
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' }, desc: string
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' }, username: string
] village: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppLogsPage() { function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' }) 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 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 ( return (
<Stack gap="xl"> <Stack gap="xl" py="md">
<Group justify="space-between" align="center"> <Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #7C3AED' }}>
<Stack gap={0}> <Stack gap="lg">
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title> <Group justify="space-between" align="center">
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text> <Stack gap={4}>
</Stack> <Group gap="xs">
<Group gap="xs"> <ThemeIcon variant="light" color="violet" size="lg" radius="md">
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button> <TbHistory size={22} />
</Group> </ThemeIcon>
</Group> <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 <TextInput
placeholder="Search activity, village, or operator..." placeholder="Search action or village..."
leftSection={<TbSearch size={16} />} 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" radius="md"
style={{ maxWidth: 500 }}
ml={40}
/> />
<Select </Stack>
placeholder="All Service Types" </Paper>
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
leftSection={<TbFilter size={16} />} {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" radius="md"
clearable withEdges={false}
siblings={1}
boundaries={1}
/> />
</Group> </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> </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 { AreaChart } from '@mantine/charts'
import { import {
Badge,
Box, Box,
Button, Button,
Card, Card,
Group, Group,
Modal,
Paper, Paper,
SegmentedControl, SegmentedControl,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Textarea,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { import {
@@ -21,7 +25,6 @@ import {
TbCalendar, TbCalendar,
TbCalendarEvent, TbCalendarEvent,
TbChartBar, TbChartBar,
TbCircleCheck,
TbEdit, TbEdit,
TbHome2, TbHome2,
TbLayoutKanban, TbLayoutKanban,
@@ -32,6 +35,10 @@ import {
TbUsersGroup, TbUsersGroup,
TbWifi TbWifi
} from 'react-icons/tb' } 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')({ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage, component: VillageDetailPage,
@@ -39,165 +46,33 @@ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
// ── Mock Data ──────────────────────────────────────────────────────────────── // ── Mock Data ────────────────────────────────────────────────────────────────
const mockVillages: Record<string, any> = { // Mock data removed as it is replaced by API calls
'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 },
},
}
// ── Chart Data Generators ───────────────────────────────────────────────────── // Remove chart data generators as they are replaced by API calls
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),
}))
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── 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 ──────────────────────────────────────────────────────────── // ── Activity Chart ────────────────────────────────────────────────────────────
type ChartPeriod = 'daily' | 'monthly' | 'yearly' type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart() { function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('monthly') const [period, setPeriod] = useState<ChartPeriod>('daily')
const dataMap: Record<ChartPeriod, any[]> = { const { data: response, isLoading } = useSWR(
daily: generateDailyData(), API_URLS.graphLogVillages(villageId, period),
monthly: generateMonthlyData(), fetcher
yearly: generateYearlyData(), )
}
const labels: Record<ChartPeriod, string> = { const labels: Record<ChartPeriod, string> = {
daily: 'Harian (14 hari terakhir)', daily: 'Daily (last 14 days)',
monthly: 'Bulanan (tahun ini)', monthly: 'Monthly (this year)',
yearly: 'Tahunan', yearly: 'Yearly',
} }
const data = dataMap[period] const data = response?.data || []
return ( return (
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -207,7 +82,7 @@ function ActivityChart() {
<TbChartBar size={14} /> <TbChartBar size={14} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0}> <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> <Text size="xs" c="dimmed">{labels[period]}</Text>
</Stack> </Stack>
</Group> </Group>
@@ -218,46 +93,52 @@ function ActivityChart() {
size="xs" size="xs"
radius="md" radius="md"
data={[ data={[
{ value: 'daily', label: 'Harian' }, { value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Bulanan' }, { value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Tahunan' }, { value: 'yearly', label: 'Yearly' },
]} ]}
/> />
</Group> </Group>
<AreaChart {isLoading ? (
h={280} <Stack h={280} align="center" justify="center">
data={data} <Text size="sm" c="dimmed">Loading chart data...</Text>
dataKey="label" </Stack>
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]} ) : (
curveType="monotone" <AreaChart
withTooltip h={280}
withDots data={data}
tickLine="none" dataKey="label"
gridAxis="x" series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
tooltipAnimationDuration={150} curveType="monotone"
fillOpacity={1} withTooltip
areaProps={{ withDots
strokeWidth: 2.5, tickLine="none"
fill: 'url(#villageAreaGrad)', gridAxis="x"
stroke: '#2563EB', tooltipAnimationDuration={150}
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))', fillOpacity={1}
}} areaProps={{
dotProps={{ strokeWidth: 2.5,
r: 4, fill: 'url(#villageAreaGrad)',
strokeWidth: 2, stroke: '#2563EB',
stroke: '#2563EB', filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
fill: 'white', }}
}} dotProps={{
> r: 4,
<defs> strokeWidth: 2,
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1"> stroke: '#2563EB',
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} /> fill: 'white',
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} /> }}
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} /> >
</linearGradient> <defs>
</defs> <linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
</AreaChart> <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> </Paper>
) )
} }
@@ -267,26 +148,156 @@ function ActivityChart() {
function VillageDetailPage() { function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' }) const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate() const navigate = useNavigate()
const 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 } }) 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) { if (!village) {
return ( return (
<Stack align="center" py="xl" gap="md"> <Stack align="center" py="xl" gap="md">
<TbBuildingCommunity size={48} color="gray" opacity={0.4} /> <TbBuildingCommunity size={48} color="gray" opacity={0.4} />
<Title order={4}>Desa tidak ditemukan</Title> <Title order={4}>Village not found</Title>
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text> <Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}> <Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Kembali ke Daftar Back to List
</Button> </Button>
</Stack> </Stack>
) )
} }
const cfg = statusConfig[village.status as keyof typeof statusConfig]
const { stats } = village
return ( return (
<Stack gap="xl"> <Stack gap="xl">
@@ -300,28 +311,29 @@ function VillageDetailPage() {
radius="md" radius="md"
onClick={goBack} onClick={goBack}
> >
Daftar Desa Village List
</Button> </Button>
{/* Action Buttons */} {/* Action Buttons */}
<Group gap="sm"> <Group gap="sm">
<Button <Button
variant="filled" variant="filled"
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'} color={village.isActive ? 'red' : 'green'}
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />} leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)} onClick={openConfirmModal}
radius="md" radius="md"
loading={isUpdating}
> >
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'} {village.isActive ? 'Deactivate' : 'Active'}
</Button> </Button>
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
leftSection={<TbEdit size={16} />} leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)} onClick={openEdit}
radius="md" radius="md"
> >
Edit Desa Edit
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -356,18 +368,18 @@ function VillageDetailPage() {
<Group gap={6}> <Group gap={6}>
<TbMapPin size={14} color="rgba(255,255,255,0.8)" /> <TbMapPin size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}> <Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} Location data not available
</Text> </Text>
</Group> </Group>
<Group gap={6}> <Group gap={6}>
<TbUser size={14} color="rgba(255,255,255,0.8)" /> <TbUser size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}> <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> </Text>
</Group> </Group>
<Group gap="xs" mt={2}> {/* <Group gap="xs" mt={2}>
<Badge <Badge
variant="outline" variant="outline"
radius="sm" radius="sm"
@@ -377,24 +389,16 @@ function VillageDetailPage() {
> >
{cfg.label} {cfg.label}
</Badge> </Badge>
<Badge </Group> */}
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>
</Stack> </Stack>
</Group> </Group>
{/* Last Sync block */} {/* Last Sync block */}
<Stack gap={4} align="flex-end"> <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}> <Group gap={6}>
<TbWifi size={15} color="rgba(255,255,255,0.9)" /> <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> </Group>
</Stack> </Stack>
</Group> </Group>
@@ -403,19 +407,25 @@ function VillageDetailPage() {
{/* ── Stats Cards ── */} {/* ── Stats Cards ── */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[ {[
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' }, { icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' }, { icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' }, { icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' }, { icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
].map((s) => ( ].map((s) => (
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card"> <Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs"> <Group justify="space-between" align="flex-start" mb="xs">
<s.icon size={18} /> <ThemeIcon size={36} radius="md" variant="light" color={s.color}>
</ThemeIcon> <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' }}> <Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{s.label} {s.label}
</Text> </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> </Card>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -430,7 +440,7 @@ function VillageDetailPage() {
}} }}
> >
{/* Left (3/4): Activity Chart */} {/* Left (3/4): Activity Chart */}
<ActivityChart /> <ActivityChart villageId={villageId} />
{/* Right (1/4): Informasi Sistem */} {/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -438,13 +448,13 @@ function VillageDetailPage() {
<ThemeIcon size={28} radius="md" variant="light" color="teal"> <ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} /> <TbCalendar size={14} />
</ThemeIcon> </ThemeIcon>
<Text fw={700} size="sm">Informasi Sistem</Text> <Text fw={700} size="sm">System Information</Text>
</Group> </Group>
<Stack gap={0}> <Stack gap={0}>
{[ {[
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) }, { label: 'Date Created', value: village.createdAt },
{ label: 'Dibuat Oleh', value: village.createdBy }, { label: 'Created By', value: '-' },
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) }, { label: 'Last Updated', value: '-' },
].map((item, idx, arr) => ( ].map((item, idx, arr) => (
<Group <Group
key={item.label} key={item.label}
@@ -463,6 +473,75 @@ function VillageDetailPage() {
</Paper> </Paper>
</Box> </Box>
{/* ── Confirmation Modal ── */}
<Modal
opened={confirmModalOpened}
onClose={closeConfirmModal}
title={<Text fw={700}>Confirm Status Change</Text>}
radius="xl"
centered
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
Cancel
</Button>
<Button
color={village.isActive ? 'red' : 'green'}
onClick={handleConfirmToggle}
loading={isUpdating}
radius="md"
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
{/* ── Edit Village Modal ── */}
<Modal
opened={editModalOpened}
onClose={closeEditModal}
title={<Text fw={700}>Edit Village Details</Text>}
radius="xl"
size="md"
>
<Stack gap="md">
<TextInput
label="Village Name"
placeholder="Enter village name"
required
value={editForm.name}
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Enter village description..."
minRows={3}
required
value={editForm.desc}
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
Cancel
</Button>
<Button
variant="filled"
color="blue"
onClick={handleEditVillage}
loading={isEditing}
radius="md"
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
) )
} }

View File

@@ -1,134 +1,82 @@
import { useState } from 'react'
import { import {
ActionIcon,
Badge, Badge,
Box,
Button,
Card,
Container, Container,
Divider,
Group, Group,
Modal,
Pagination,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack, Stack,
Text, Text,
Title, Textarea,
Paper,
Button,
ActionIcon,
TextInput, TextInput,
Tooltip,
SimpleGrid,
Avatar,
Box,
SegmentedControl,
Card,
Divider,
ThemeIcon, ThemeIcon,
Title,
Tooltip
} from '@mantine/core' } from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { useDisclosure } from '@mantine/hooks'
import { import {
TbPlus, TbArrowRight,
TbSearch,
TbBuildingCommunity, TbBuildingCommunity,
TbCalendar,
TbChevronRight,
TbHome2,
TbLayoutGrid, TbLayoutGrid,
TbList, TbList,
TbMapPin, TbMapPin,
TbCalendar, TbPlus,
TbSearch,
TbUser, TbUser,
TbHome2, TbX,
TbArrowRight,
TbChevronRight,
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/villages/')({ export const Route = createFileRoute('/apps/$appId/villages/')({
component: AppVillagesIndexPage, component: AppVillagesIndexPage,
}) })
const mockVillages = [ interface APIVillage {
{ id: string
id: 'sukatani', name: string
name: 'Sukatani', isActive: boolean
kecamatan: 'Tapos', createdAt: string
kabupaten: 'Kota Depok', perbekel: string | null
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,
},
]
const statusConfig = { const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi' }, 'active': { color: 'teal', label: 'Active' },
'sync active': { color: 'blue', label: 'Sync Aktif' }, 'inactive': { color: 'orange', label: 'Inactive' },
'sync pending': { color: 'orange', label: 'Sync Pending' },
} }
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', { if (!dateStr) return '-'
day: 'numeric', try {
month: 'short', return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric', day: 'numeric',
}) month: 'short',
year: 'numeric',
})
} catch (e) {
return dateStr
}
} }
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) { function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig] const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return ( return (
<Card <Card
withBorder withBorder
@@ -158,12 +106,12 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<Group gap={4} mb="md"> <Group gap={4} mb="md">
<TbMapPin size={13} color="var(--mantine-color-dimmed)" /> <TbMapPin size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten} No location details available
</Text> </Text>
</Group> </Group>
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}> <Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{village.provinsi} -
</Text> </Text>
<Divider my="sm" /> <Divider my="sm" />
@@ -171,19 +119,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<Stack gap={6}> <Stack gap={6}>
<Group gap="xs"> <Group gap="xs">
<TbUser size={13} color="var(--mantine-color-dimmed)" /> <TbUser size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Perbekel:</Text> <Text size="xs" c="dimmed">Village Head:</Text>
<Text size="xs" fw={600}>{village.perbekel}</Text> <Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
<TbCalendar size={13} color="var(--mantine-color-dimmed)" /> <TbCalendar size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Dibuat:</Text> <Text size="xs" c="dimmed">Created:</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text> <Text size="xs" fw={600}>{village.createdAt}</Text>
</Group> </Group>
<Group gap="xs"> {/* <Group gap="xs">
<Avatar size={14} radius="xl" color="brand-blue" src={null} /> <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> <Text size="xs" fw={600}>{village.createdBy}</Text>
</Group> </Group> */}
</Stack> </Stack>
<Button <Button
@@ -196,14 +144,15 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
rightSection={<TbArrowRight size={14} />} rightSection={<TbArrowRight size={14} />}
styles={{ root: { fontSize: 12 } }} styles={{ root: { fontSize: 12 } }}
> >
Lihat Detail View Details
</Button> </Button>
</Card> </Card>
) )
} }
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) { function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig] const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return ( return (
<Paper <Paper
withBorder withBorder
@@ -233,7 +182,7 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<Group gap={6}> <Group gap={6}>
<TbMapPin size={12} color="var(--mantine-color-dimmed)" /> <TbMapPin size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} No location details available
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
@@ -241,20 +190,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<Group gap="xl" visibleFrom="md"> <Group gap="xl" visibleFrom="md">
<Stack gap={0} align="center"> <Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>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> <Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Stack> </Stack>
<Stack gap={0} align="center"> <Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text> <Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text> <Text size="xs" fw={600}>{village.createdAt}</Text>
</Stack> </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> <Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
<Group gap={4}> <Group gap={4}>
<Avatar size={16} radius="xl" color="brand-blue" src={null} /> <Avatar size={16} radius="xl" color="brand-blue" src={null} />
<Text size="xs" fw={600}>{village.createdBy}</Text> <Text size="xs" fw={600}>{village.createdBy}</Text>
</Group> </Group>
</Stack> </Stack> */}
</Group> </Group>
<ActionIcon variant="light" color="brand-blue" radius="md"> <ActionIcon variant="light" color="brand-blue" radius="md">
@@ -269,21 +218,111 @@ function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' }) const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate() const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') 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 [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 isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
const filtered = mockVillages.filter((v) => const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel] const villages: APIVillage[] = response?.data || []
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
)
const handleVillageClick = (villageId: string) => { const handleVillageClick = (villageId: string) => {
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } }) 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) { if (!isDesaPlus) {
return ( return (
<Container size="xl" py="xl"> <Container size="xl" py="xl">
@@ -298,11 +337,105 @@ function AppVillagesIndexPage() {
return ( return (
<Stack gap="xl"> <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"> <Group justify="space-between" align="flex-end">
<Stack gap={4}> <Stack gap={4}>
<Title order={3}>Daftar Desa</Title> <Title order={3}>Village List</Title>
<Text size="sm" c="dimmed"> <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> </Text>
</Stack> </Stack>
<Button <Button
@@ -310,17 +443,25 @@ function AppVillagesIndexPage() {
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
radius="md" radius="md"
onClick={openCreateModal}
> >
Tambah Desa Create New Village
</Button> </Button>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<TextInput <TextInput
placeholder="Cari desa, kecamatan, kabupaten..." placeholder="Search village, district, city..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={14} />
</ActionIcon>
) : null
}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md" radius="md"
style={{ flex: 1, maxWidth: 400 }} style={{ flex: 1, maxWidth: 400 }}
/> />
@@ -335,14 +476,27 @@ function AppVillagesIndexPage() {
/> />
</Group> </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' }}> <Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="gray" opacity={0.4} /> <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> </Paper>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{filtered.map((village) => ( {villages.map((village) => (
<VillageGridCard <VillageGridCard
key={village.id} key={village.id}
village={village} village={village}
@@ -352,7 +506,7 @@ function AppVillagesIndexPage() {
</SimpleGrid> </SimpleGrid>
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
{filtered.map((village) => ( {villages.map((village) => (
<VillageListRow <VillageListRow
key={village.id} key={village.id}
village={village} village={village}
@@ -361,6 +515,18 @@ function AppVillagesIndexPage() {
))} ))}
</Stack> </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> </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 { import {
Badge, Badge,
Button, Button,
Container, Container,
Group, Group,
Loader,
Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table,
Text, Text,
Title, Title,
Paper,
Table,
Loader,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute, redirect, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query'
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb' import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { AppCard } from '@/frontend/components/AppCard'
export const Route = createFileRoute('/dashboard')({ export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -65,7 +65,7 @@ function DashboardPage() {
<Title order={2} className="gradient-text">Overview Dashboard</Title> <Title order={2} className="gradient-text">Overview Dashboard</Title>
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text> <Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
</Stack> </Stack>
<Button {/* <Button
variant="gradient" variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbApps size={18} />} leftSection={<TbApps size={18} />}
@@ -74,7 +74,7 @@ function DashboardPage() {
to="/apps" to="/apps"
> >
Manage All Apps Manage All Apps
</Button> </Button> */}
</Group> </Group>
{statsLoading ? ( {statsLoading ? (
@@ -107,8 +107,8 @@ function DashboardPage() {
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Title order={3}>Registered Applications</Title> <Title order={3}>Registered Applications</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}> <Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/apps">
View Report View All Apps
</Button> </Button>
</Group> </Group>

View File

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

View File

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

View File

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

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

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