From c0205ce2bf02b7990943ddb817306b69406ee634 Mon Sep 17 00:00:00 2001 From: amal Date: Mon, 13 Apr 2026 14:48:49 +0800 Subject: [PATCH] upd: user dan log activity --- .../20260413034305_add/migration.sql | 12 + .../20260413044057_add/migration.sql | 2 + prisma/schema.prisma | 11 +- src/app.ts | 100 ++++- src/frontend/config/api.ts | 6 + src/frontend/routes/dashboard.tsx | 28 +- src/frontend/routes/logs.tsx | 386 +++++++++++------- src/frontend/routes/users.tsx | 150 ++++--- 8 files changed, 467 insertions(+), 228 deletions(-) create mode 100644 prisma/migrations/20260413034305_add/migration.sql create mode 100644 prisma/migrations/20260413044057_add/migration.sql 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. - + */} {statsLoading ? ( @@ -107,8 +107,8 @@ function DashboardPage() { Registered Applications - 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) + }} /> { + setOperatorId(val) + 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) + }} />