upd: user dan log activity
This commit is contained in:
12
prisma/migrations/20260413034305_add/migration.sql
Normal file
12
prisma/migrations/20260413034305_add/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `type` on the `log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LogType" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "log" DROP COLUMN "type",
|
||||||
|
ADD COLUMN "type" "LogType" NOT NULL;
|
||||||
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "image" TEXT;
|
||||||
@@ -35,6 +35,14 @@ enum BugStatus{
|
|||||||
CLOSED
|
CLOSED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LogType{
|
||||||
|
CREATE
|
||||||
|
UPDATE
|
||||||
|
DELETE
|
||||||
|
LOGIN
|
||||||
|
LOGOUT
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
@@ -42,6 +50,7 @@ model User {
|
|||||||
password String
|
password String
|
||||||
role Role @default(USER)
|
role Role @default(USER)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -69,7 +78,7 @@ model Session {
|
|||||||
model Log {
|
model Log {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
type String
|
type LogType
|
||||||
message String
|
message String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
100
src/app.ts
100
src/app.ts
@@ -153,8 +153,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 +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' }
|
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 ───────────────────────────────────
|
// ─── Example API ───────────────────────────────────
|
||||||
.get('/api/hello', () => ({
|
.get('/api/hello', () => ({
|
||||||
message: 'Hello, world!',
|
message: 'Hello, world!',
|
||||||
|
|||||||
@@ -25,4 +25,10 @@ export const API_URLS = {
|
|||||||
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
|
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
|
||||||
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
|
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
|
||||||
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-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`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,38 +131,59 @@ 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) => (
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredTimeline.map((group, groupIndex) => (
|
||||||
<Box key={group.date}>
|
<Box key={group.date}>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -163,50 +236,51 @@ function GlobalLogsPage() {
|
|||||||
)}
|
)}
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<Box style={{ position: 'relative', zIndex: 2 }}>
|
<Box style={{ position: 'relative', zIndex: 2 }}>
|
||||||
{log.icon ? (
|
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
|
||||||
<Avatar size={24} radius="xl" color={log.color} variant="light">
|
<Avatar
|
||||||
<log.icon size={14} />
|
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>
|
</Avatar>
|
||||||
) : (
|
</Tooltip>
|
||||||
<Avatar size={24} radius="xl" color={log.color}>
|
|
||||||
{log.operator.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right: Content */}
|
{/* Right: Content */}
|
||||||
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
|
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
|
||||||
{log.content}
|
{log.content}
|
||||||
</Text>
|
</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>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{log.message.text}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{groupIndex < timelineData.length - 1 && (
|
{groupIndex < filteredTimeline.length - 1 && (
|
||||||
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{response?.totalPages > 1 && (
|
||||||
|
<Center mt="xl">
|
||||||
|
<Pagination
|
||||||
|
total={response.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -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,17 +142,31 @@ 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>
|
||||||
|
<Table.Td colSpan={4} align="center">
|
||||||
|
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : operators.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4} align="center">
|
||||||
|
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
operators.map((user: any) => (
|
||||||
<Table.Tr key={user.id}>
|
<Table.Tr key={user.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
|
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={600} size="sm">{user.name}</Text>
|
<Text fw={600} size="sm">{user.name}</Text>
|
||||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||||
@@ -135,22 +174,12 @@ function UsersPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
|
<Badge variant="light" color={getRoleColor(user.role)}>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={6}>
|
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||||
<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>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -163,10 +192,22 @@ function UsersPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user