diff --git a/prisma/migrations/20260413034305_add/migration.sql b/prisma/migrations/20260413034305_add/migration.sql
new file mode 100644
index 0000000..d61c9e9
--- /dev/null
+++ b/prisma/migrations/20260413034305_add/migration.sql
@@ -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;
diff --git a/prisma/migrations/20260413044057_add/migration.sql b/prisma/migrations/20260413044057_add/migration.sql
new file mode 100644
index 0000000..bfa3665
--- /dev/null
+++ b/prisma/migrations/20260413044057_add/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "user" ADD COLUMN "image" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index baa9904..35dbf84 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -35,6 +35,14 @@ enum BugStatus{
CLOSED
}
+enum LogType{
+ CREATE
+ UPDATE
+ DELETE
+ LOGIN
+ LOGOUT
+}
+
model User {
id String @id @default(uuid())
name String
@@ -42,6 +50,7 @@ model User {
password String
role Role @default(USER)
active Boolean @default(true)
+ image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -69,7 +78,7 @@ model Session {
model Log {
id String @id @default(uuid())
userId String
- type String
+ type LogType
message String
createdAt DateTime @default(now())
diff --git a/src/app.ts b/src/app.ts
index d5b568e..4c90e03 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -153,8 +153,8 @@ export function createApp() {
.get('/api/apps', () => [
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
- { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
- { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
+ // { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
+ // { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
])
.get('/api/apps/:appId', ({ params: { appId } }) => {
@@ -164,6 +164,102 @@ export function createApp() {
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
})
+ .get('/api/logs', async ({ query }) => {
+ const page = Number(query.page) || 1
+ const limit = Number(query.limit) || 20
+ const search = (query.search as string) || ''
+ const type = query.type as any
+ const userId = query.userId as string
+
+ const where: any = {}
+ if (search) {
+ where.OR = [
+ { message: { contains: search, mode: 'insensitive' } },
+ { user: { name: { contains: search, mode: 'insensitive' } } }
+ ]
+ }
+ if (type && type !== 'all') {
+ where.type = type
+ }
+ if (userId && userId !== 'all') {
+ where.userId = userId
+ }
+
+ const [logs, total] = await Promise.all([
+ prisma.log.findMany({
+ where,
+ include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * limit,
+ take: limit,
+ }),
+ prisma.log.count({ where })
+ ])
+
+ return {
+ data: logs,
+ totalPages: Math.ceil(total / limit),
+ totalItems: total
+ }
+ })
+
+ .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' }
+ })
+ })
+
// ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({
message: 'Hello, world!',
diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts
index d5c8c4e..bb14c69 100644
--- a/src/frontend/config/api.ts
+++ b/src/frontend/config/api.ts
@@ -25,4 +25,10 @@ export const API_URLS = {
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`,
}
diff --git a/src/frontend/routes/dashboard.tsx b/src/frontend/routes/dashboard.tsx
index 1b1c14f..a31dcb7 100644
--- a/src/frontend/routes/dashboard.tsx
+++ b/src/frontend/routes/dashboard.tsx
@@ -1,23 +1,23 @@
-import { useQuery } from '@tanstack/react-query'
+import { AppCard } from '@/frontend/components/AppCard'
+import { DashboardLayout } from '@/frontend/components/DashboardLayout'
+import { StatsCard } from '@/frontend/components/StatsCard'
+import { useSession } from '@/frontend/hooks/useAuth'
import {
Badge,
Button,
Container,
Group,
+ Loader,
+ Paper,
SimpleGrid,
Stack,
+ Table,
Text,
Title,
- Paper,
- Table,
- Loader,
} from '@mantine/core'
-import { createFileRoute, redirect, Link } from '@tanstack/react-router'
-import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb'
-import { useLogout, useSession } from '@/frontend/hooks/useAuth'
-import { DashboardLayout } from '@/frontend/components/DashboardLayout'
-import { StatsCard } from '@/frontend/components/StatsCard'
-import { AppCard } from '@/frontend/components/AppCard'
+import { useQuery } from '@tanstack/react-query'
+import { createFileRoute, Link, redirect } from '@tanstack/react-router'
+import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
@@ -65,7 +65,7 @@ function DashboardPage() {
Overview Dashboard
Welcome back, {user?.name}. Here is what's happening today.
- }
@@ -74,7 +74,7 @@ function DashboardPage() {
to="/apps"
>
Manage All Apps
-
+ */}
{statsLoading ? (
@@ -107,8 +107,8 @@ function DashboardPage() {
Registered Applications
- }>
- View Report
+ } component={Link} to="/apps">
+ View All Apps
diff --git a/src/frontend/routes/logs.tsx b/src/frontend/routes/logs.tsx
index 4fbc5e6..123fa8b 100644
--- a/src/frontend/routes/logs.tsx
+++ b/src/frontend/routes/logs.tsx
@@ -10,67 +10,119 @@ import {
Avatar,
Box,
Divider,
+ Pagination,
+ Center,
+ Tooltip,
} from '@mantine/core'
-import { useState, useMemo } from 'react'
+import { useState, useMemo, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
+import useSWR from 'swr'
+import { API_URLS } from '../config/api'
export const Route = createFileRoute('/logs')({
component: GlobalLogsPage,
})
-const timelineData = [
- {
- date: 'TODAY',
- logs: [
- { id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document Surat Domisili for Sukatani> },
- { id: 2, time: '11:42 AM', operator: 'Siti Aminah', app: 'Desa+', color: 'teal', content: <>uploaded financial report Realisasi Q1 for Sukamaju> },
- { id: 3, time: '10:12 AM', operator: 'System', app: 'Desa+', color: 'red', icon: TbX, content: <>experienced failure in SIAK Sync at }>Cikini>, message: { title: 'Sync Operation Failed (NullPointerException)', text: 'NullPointerException at village_sync.dart:45. The server returned a timeout error while waiting for the master database replica connection. Auto-retry scheduled in 15 minutes.' } },
- { id: 4, time: '09:42 AM', operator: 'Jane Smith', app: 'E-Commerce', color: 'orange', icon: TbCheck, content: <>resolved payment gateway issue for E-Commerce checkout> },
- ]
- },
- {
- date: 'YESTERDAY',
- logs: [
- { id: 5, time: '05:10 AM', operator: 'System', app: 'System', color: 'cyan', content: <>completed automated Nightly Backup for all 138 villages> },
- { id: 6, time: '04:50 AM', operator: 'Rahmat Hidayat', app: 'Desa+', color: 'green', content: <>granted Admin access to Desa Bojong Gede operator> },
- { id: 7, time: '03:42 AM', operator: 'System', app: 'Fitness App', color: 'red', icon: TbX, content: <>detected SocketException across Fitness App wearable sync operations.> },
- { id: 8, time: '02:33 AM', operator: 'Agus Setiawan', app: 'Desa+', color: 'blue', content: <>verified 145 Surat Kematian entries in batch.> },
- ]
- },
- {
- date: '12 APRIL, 2026',
- logs: [
- { id: 9, time: '03:42 AM', operator: 'Amel', app: 'Desa+', color: 'indigo', content: <>changed version configurations rolling out Desa+ v2.4.1> },
- { id: 10, time: '02:10 AM', operator: 'John Doe', app: 'E-Commerce', color: 'pink', content: <>updated App setting Require OTP on Login View Details> },
- ]
- }
-]
+const fetcher = (url: string) => fetch(url).then((res) => res.json())
+
+const typeConfig: Record = {
+ CREATE: { color: 'blue', icon: TbCheck },
+ UPDATE: { color: 'teal', icon: TbCheck },
+ DELETE: { color: 'red', icon: TbX },
+ LOGIN: { color: 'green', icon: TbClock },
+ LOGOUT: { color: 'orange', icon: TbClock },
+}
+
+const getRoleColor = (role: string) => {
+ const r = (role || '').toLowerCase()
+ if (r.includes('super')) return 'red'
+ if (r.includes('admin')) return 'brand-blue'
+ if (r.includes('developer')) return 'violet'
+ return 'gray'
+}
+
+function groupLogsByDate(logs: any[]) {
+ const groups: Record = {}
+
+ const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
+ const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
+
+ logs.forEach(log => {
+ const dateObj = new Date(log.createdAt)
+ let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
+
+ if (dateStr === today) dateStr = 'TODAY'
+ else if (dateStr === yesterday) dateStr = 'YESTERDAY'
+
+ if (!groups[dateStr]) groups[dateStr] = []
+
+ const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
+
+ groups[dateStr].push({
+ id: log.id,
+ time: timeStr,
+ user: log.user,
+ type: log.type,
+ content: log.message,
+ color: log.user ? getRoleColor(log.user.role) : 'gray',
+ icon: typeConfig[log.type as string]?.icon
+ })
+ })
+
+ // We want to keep the order as they came from the API (sorted by createdAt desc)
+ // but grouped by date. Object.entries might mess up the order if dates are not sequential.
+ // However, since the source logs are sorted, the first encounter of a date defines the group order.
+ const result: { date: string; logs: any[] }[] = []
+ const seenDates = new Set()
+
+ logs.forEach(log => {
+ const dateObj = new Date(log.createdAt)
+ let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
+ if (dateStr === today) dateStr = 'TODAY'
+ else if (dateStr === yesterday) dateStr = 'YESTERDAY'
+
+ if (!seenDates.has(dateStr)) {
+ result.push({ date: dateStr, logs: groups[dateStr] })
+ seenDates.add(dateStr)
+ }
+ })
+
+ return result
+}
function GlobalLogsPage() {
const [search, setSearch] = useState('')
- const [appFilter, setAppFilter] = useState(null)
- const [operatorFilter, setOperatorFilter] = useState(null)
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ const [logType, setLogType] = useState('all')
+ const [operatorId, setOperatorId] = useState('all')
+ const [page, setPage] = useState(1)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedSearch(search), 300)
+ return () => clearTimeout(timer)
+ }, [search])
+
+ const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
+
+ const operatorOptions = useMemo(() => {
+ if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
+ return [
+ { value: 'all', label: 'All Operators' },
+ ...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
+ ]
+ }, [operatorsData])
+
+ const { data: response, isLoading } = useSWR(
+ API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
+ fetcher
+ )
const filteredTimeline = useMemo(() => {
- return timelineData
- .map(group => {
- const filteredLogs = group.logs.filter(log => {
- if (appFilter && log.app !== appFilter) return false;
- if (operatorFilter && log.operator !== operatorFilter) return false;
- if (search) {
- const lSearch = search.toLowerCase();
- if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
- return false;
- }
- }
- return true;
- });
- return { ...group, logs: filteredLogs };
- })
- .filter(group => group.logs.length > 0);
- }, [search, appFilter, operatorFilter]);
+ if (!response?.data) return []
+ return groupLogsByDate(response.data)
+ }, [response?.data])
return (
@@ -79,134 +131,156 @@ function GlobalLogsPage() {
{/* Header Controls */}
}
radius="md"
- w={220}
+ w={250}
value={search}
- onChange={(e) => setSearch(e.currentTarget.value)}
+ onChange={(e) => {
+ setSearch(e.currentTarget.value)
+ setPage(1)
+ }}
/>
{/* Timeline Content */}
-
- {filteredTimeline.length === 0 ? (
+
+ {isLoading ? (
+
+ Loading logs...
+
+ ) : filteredTimeline.length === 0 ? (
No logs found matching your filters.
- ) : filteredTimeline.map((group, groupIndex) => (
-
- 0 ? "xl" : 0}
- mb="lg"
- style={{ textTransform: 'uppercase' }}
- >
- {group.date}
-
-
-
- {group.logs.map((log, logIndex) => {
- const isLastLog = logIndex === group.logs.length - 1;
+ ) : (
+ <>
+ {filteredTimeline.map((group, groupIndex) => (
+
+ 0 ? "xl" : 0}
+ mb="lg"
+ style={{ textTransform: 'uppercase' }}
+ >
+ {group.date}
+
- return (
-
- {/* Left: Time */}
-
- {log.time}
-
-
- {/* Middle: Line & Avatar */}
-
- {/* Vertical Line */}
- {!isLastLog && (
-
- )}
- {/* Avatar */}
-
- {log.icon ? (
-
-
-
- ) : (
-
- {log.operator.charAt(0)}
-
- )}
-
-
-
- {/* Right: Content */}
-
-
- {log.operator}
- {log.content}
-
-
- {log.message && (
-
+ {group.logs.map((log, logIndex) => {
+ const isLastLog = logIndex === group.logs.length - 1;
+
+ return (
+
+ {/* Left: Time */}
+
- {log.message.title}
-
- {log.message.text}
+ {log.time}
+
+
+ {/* Middle: Line & Avatar */}
+
+ {/* Vertical Line */}
+ {!isLastLog && (
+
+ )}
+ {/* Avatar */}
+
+
+
+ {log.icon ? : (log.user?.name?.charAt(0) || '?')}
+
+
+
+
+
+ {/* Right: Content */}
+
+
+ {log.user?.name || 'Unknown'}
+ {log.content}
-
- )}
-
-
- )
- })}
-
-
- {groupIndex < timelineData.length - 1 && (
-
+
+
+ )
+ })}
+
+
+ {groupIndex < filteredTimeline.length - 1 && (
+
+ )}
+
+ ))}
+
+ {response?.totalPages > 1 && (
+
+
+
)}
-
- ))}
+ >
+ )}
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx
index fcbf60a..ec0a12b 100644
--- a/src/frontend/routes/users.tsx
+++ b/src/frontend/routes/users.tsx
@@ -16,10 +16,11 @@ import {
SimpleGrid,
ThemeIcon,
List,
- Box,
Divider,
+ Pagination,
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
+import { useState, useEffect } from 'react'
import {
TbPlus,
TbSearch,
@@ -34,40 +35,59 @@ import {
} from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
+import useSWR from 'swr'
+import { API_URLS } from '../config/api'
export const Route = createFileRoute('/users')({
component: UsersPage,
})
-const mockUsers = [
- { id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
- { id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' },
- { id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
- { id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
-]
+const fetcher = (url: string) => fetch(url).then((res) => res.json())
+
+const getRoleColor = (role: string) => {
+ const r = (role || '').toLowerCase()
+ if (r.includes('super')) return 'red'
+ if (r.includes('admin')) return 'brand-blue'
+ if (r.includes('developer')) return 'violet'
+ return 'gray'
+}
const roles = [
{
name: 'SUPER_ADMIN',
- count: 2,
color: 'red',
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
},
{
name: 'DEVELOPER',
- count: 12,
color: 'brand-blue',
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
},
{
name: 'QA',
- count: 5,
color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
},
]
function UsersPage() {
+ const [search, setSearch] = useState('')
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ const [page, setPage] = useState(1)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedSearch(search), 300)
+ return () => clearTimeout(timer)
+ }, [search])
+
+ const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher)
+ const { data: response, isLoading } = useSWR(
+ API_URLS.getOperators(page, debouncedSearch),
+ fetcher
+ )
+
+ const operators = response?.data || []
+
return (
@@ -80,9 +100,9 @@ function UsersPage() {
-
-
-
+
+
+
@@ -100,6 +120,11 @@ function UsersPage() {
radius="md"
w={350}
variant="filled"
+ value={search}
+ onChange={(e) => {
+ setSearch(e.currentTarget.value)
+ setPage(1)
+ }}
/>