upd: tampilan

This commit is contained in:
2026-04-02 10:30:21 +08:00
parent 39d659acd0
commit 47d26799ad
28 changed files with 2701 additions and 237 deletions

View File

@@ -143,6 +143,27 @@ export function createApp() {
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
})
// ─── Monitoring API ────────────────────────────────
.get('/api/dashboard/stats', () => ({
totalApps: 3,
newErrors: 185,
activeUsers: '24.5k',
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 }
}))
.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' },
])
.get('/api/apps/:appId', ({ params: { appId } }) => {
const apps = {
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
}
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
})
// ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({
message: 'Hello, world!',

View File

@@ -5,8 +5,38 @@ import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const theme = createTheme({
primaryColor: 'blue',
primaryColor: 'brand-blue',
colors: {
'brand-blue': [
'#ebf2ff',
'#d6e4ff',
'#adc8ff',
'#85acff',
'#5c90ff',
'#2563eb', // Primary Blue
'#1e4fb8',
'#173b85',
'#102752',
'#09131f',
],
'brand-purple': [
'#f3ebff',
'#e7d6ff',
'#cfadff',
'#b785ff',
'#9f5cff',
'#7c3aed', // Primary Purple
'#632eb8',
'#4a2285',
'#311652',
'#180b1f',
],
},
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
headings: {
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600',
},
})
const queryClient = new QueryClient({

View File

@@ -0,0 +1,102 @@
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box } from '@mantine/core'
import { Link } from '@tanstack/react-router'
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
interface AppCardProps {
id: string
name: string
status: 'active' | 'warning' | 'error'
users: number
errors: number
version: string
}
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
return (
<Card
withBorder
padding="xl"
radius="2xl"
className="premium-card glass"
styles={(theme) => ({
root: {
backgroundColor: 'rgba(30, 41, 59, 0.4)',
borderColor: 'rgba(255,255,255,0.08)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 12px 24px -8px rgba(0, 0, 0, 0.4)',
borderColor: 'rgba(37, 99, 235, 0.3)',
},
},
})}
>
<Group justify="space-between" mb="lg">
<Group gap="md">
<Avatar
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
radius="lg"
size="lg"
>
<TbDeviceMobile size={26} />
</Avatar>
<Stack gap={0}>
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
<Text size="xs" c="dimmed" fw={600}>BUILD v{version}</Text>
</Stack>
</Group>
<Badge color={statusColor} variant="dot" size="sm">
{status.toUpperCase()}
</Badge>
</Group>
<Stack gap="md" mt="sm">
<Box>
<Group justify="space-between" mb={6}>
<Group gap="xs">
<TbActivity size={16} color="#2563EB" />
<Text size="xs" fw={700} c="dimmed">USER ADOPTION</Text>
</Group>
<Text size="sm" fw={700}>{users.toLocaleString()}</Text>
</Group>
<Progress value={85} size="sm" color="brand-blue" radius="xl" />
</Box>
<Box>
<Group justify="space-between" mb={6}>
<Group gap="xs">
<TbAlertTriangle size={16} color={errors > 0 ? '#ef4444' : '#64748b'} />
<Text size="xs" fw={700} c="dimmed">HEALTH INCIDENTS</Text>
</Group>
<Text size="sm" fw={700} color={errors > 0 ? 'red' : 'dimmed'}>{errors}</Text>
</Group>
<Progress value={errors > 0 ? 30 : 0} size="sm" color="red" radius="xl" />
</Box>
</Stack>
<Button
component={Link}
to={`/apps/${id}`}
variant="light"
color="brand-blue"
fullWidth
mt="xl"
radius="md"
rightSection={<TbChevronRight size={16} />}
styles={{
root: {
height: '44px',
'&:hover': {
backgroundColor: 'rgba(37, 99, 235, 0.15)',
}
}
}}
>
Center Intelligence
</Button>
</Card>
)
}

View File

@@ -0,0 +1,127 @@
import {
Paper,
Stack,
Text,
Group,
ThemeIcon,
Box,
Badge,
useMantineTheme
} from '@mantine/core'
import { LineChart, BarChart } from '@mantine/charts'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
const activityData = [
{ date: 'Mar 26', logs: 1200 },
{ date: 'Mar 27', logs: 1900 },
{ date: 'Mar 28', logs: 1540 },
{ date: 'Mar 29', logs: 2400 },
{ date: 'Mar 30', logs: 2100 },
{ date: 'Mar 31', logs: 3200 },
{ date: 'Apr 01', logs: 3800 },
]
const villageComparisonData = [
{ village: 'Sukatani', activity: 4500 },
{ village: 'Sukamaju', activity: 3800 },
{ village: 'Bojong Gede', activity: 3200 },
{ village: 'Beji', activity: 2800 },
{ village: 'Tapos', activity: 2400 },
]
export function VillageActivityLineChart() {
const theme = useMantineTheme()
return (
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
<Stack gap="md" h="100%">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon variant="light" size="lg" radius="md" color="blue">
<TbTimeline size={20} />
</ThemeIcon>
<Box>
<Text fw={700} size="sm">DAILY ACTIVITY - ALL VILLAGES</Text>
<Text size="xs" c="dimmed">Trend over the last 7 days</Text>
</Box>
</Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
Growing
</Badge>
</Group>
<Box h={300} mt="lg">
<LineChart
h={300}
data={activityData}
dataKey="date"
series={[{ name: 'logs', color: '#2563EB' }]}
curveType="monotone"
tickLine="none"
gridAxis="x"
withTooltip
tooltipAnimationDuration={200}
styles={{
root: {
'.recharts-line-curve': {
strokeWidth: 3,
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))'
}
}
}}
/>
</Box>
</Stack>
</Paper>
)
}
export function VillageComparisonBarChart() {
const theme = useMantineTheme()
return (
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
<Stack gap="md" h="100%">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon variant="light" size="lg" radius="md" color="brand-purple">
<TbChartBar size={20} />
</ThemeIcon>
<Box>
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text>
</Box>
</Group>
</Group>
<Box h={300} mt="lg">
<BarChart
h={300}
data={villageComparisonData}
dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]}
withTooltip
tickLine="none"
gridAxis="y"
barProps={{
radius: [8, 8, 4, 4],
}}
styles={{
bar: {
fill: 'url(#barGradient)',
}
}}
>
{/* Custom SVG Gradient definitions for Premium SaaS look */}
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0.8} />
</linearGradient>
</defs>
</BarChart>
</Box>
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,228 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus'
import {
AppShell,
Avatar,
Box,
Burger,
Button,
Group,
Menu,
NavLink,
Select,
Stack,
Text,
ThemeIcon
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import {
TbApps,
TbArrowLeft,
TbChevronRight,
TbDashboard,
TbDeviceMobile,
TbLogout,
TbSettings,
TbUserCircle
} from 'react-icons/tb'
interface DashboardLayoutProps {
children: React.ReactNode
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [opened, { toggle }] = useDisclosure()
const location = useLocation()
const navigate = useNavigate()
const { appId } = useParams({ strict: false }) as { appId?: string }
const matches = useMatches()
const currentPath = matches[matches.length - 1]?.pathname
const globalNav = [
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Settings', icon: TbSettings, to: '/settings' },
]
const activeApp = appId ? APP_CONFIGS[appId] : null
const navLinks = activeApp ? activeApp.menus : globalNav
return (
<AppShell
header={{ height: 70 }}
navbar={{
width: 260,
breakpoint: 'sm',
collapsed: { mobile: !opened },
}}
padding="xl"
styles={(theme) => ({
main: {
backgroundColor: theme.colors.dark[7], // Dark mode background
},
})}
>
<AppShell.Header px="xl">
<Group h="100%" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Group gap="xs">
<ThemeIcon
size={34}
radius="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
>
<TbDeviceMobile size={18} />
</ThemeIcon>
<Text
size="lg"
fw={700}
className="gradient-text"
style={{ letterSpacing: '-0.5px' }}
>
Monitoring System
</Text>
</Group>
</Group>
<Group gap="md">
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<Avatar
src={undefined}
alt="User"
color="brand-blue"
radius="xl"
style={{ cursor: 'pointer' }}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Application</Menu.Label>
<Menu.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item>
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item>
<Menu.Divider />
<Menu.Label>Danger Zone</Menu.Label>
<Menu.Item color="red" leftSection={<TbLogout size={16} />}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<Stack gap="xs" mt="md">
{activeApp && (
<NavLink
label="Back to Dashboard"
leftSection={<TbArrowLeft size={20} />}
component={Link}
to="/dashboard"
styles={(theme) => ({
root: {
borderRadius: theme.radius.md,
opacity: 0.7,
'&:hover': { opacity: 1 },
},
})}
/>
)}
{
activeApp &&
<Select
label="Selected Application"
value={appId}
data={[
{ value: 'desa-plus', label: 'Desa+' },
{ value: 'e-commerce', label: 'E-Commerce' },
{ value: 'fitness-app', label: 'Fitness App' },
]}
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
radius="md"
size="sm"
w={220}
mb={"md"}
variant="filled"
styles={(theme) => ({
input: { border: '1px solid rgba(255,255,255,0.1)' }
})}
/>
}
{/* {activeApp && (
<Text size="xs" fw={700} c="dimmed" px="md" mb="xs" style={{ textTransform: 'uppercase' }}>
{activeApp.name} Context
</Text>
)} */}
{navLinks.map((link: any) => {
const isActive = currentPath === link.to
return (
<NavLink
key={link.label}
component={Link}
to={link.to}
activeOptions={{ exact: true }}
label={link.label}
leftSection={<link.icon size={20} />}
rightSection={<TbChevronRight size={14} />}
active={isActive}
variant="filled"
color="brand-blue"
className="sidebar-nav-item"
styles={(theme) => ({
root: {
borderRadius: theme.radius.md,
transition: 'all 0.2s ease',
'&[data-active]': {
background: 'var(--gradient-blue-purple)',
fontWeight: 600,
},
},
})}
/>
)
})}
</Stack>
<Box style={{ marginTop: 'auto' }}>
<Stack gap="xs">
<Box
p="md"
className="glass"
style={{ borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}
>
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
<Group gap="xs">
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} />
<Text size="sm" fw={500}>All Systems Operational</Text>
</Group>
</Box>
<Button
variant="light"
color="red"
fullWidth
leftSection={<TbLogout size={16} />}
mt="md"
>
Log out
</Button>
</Stack>
</Box>
</AppShell.Navbar>
<AppShell.Main>
{children}
</AppShell.Main>
</AppShell>
)
}

View File

@@ -0,0 +1,195 @@
import {
Table,
Badge,
Text,
Paper,
Group,
Drawer,
Stack,
Divider,
Code,
Button,
Box,
ScrollArea,
Title
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react'
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
const mockErrors = [
{
id: 1,
message: 'NullPointerException at village_sync.dart:45',
village: 'Sukatani',
version: 'v1.2.0',
timestamp: '2026-04-01 14:30:15',
severity: 'critical',
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)'
},
{
id: 2,
message: 'Failed to load citizen record session',
village: 'Sukamaju',
version: 'v1.1.8',
timestamp: '2026-04-01 14:15:22',
severity: 'high',
stackTrace: 'Error: Connection timeout reaching upstream citizen-db\n at HttpClient.get (network.dart:88)'
},
{
id: 3,
message: 'SocketException: Connection timed out',
village: 'Cikini',
version: 'v1.2.0',
timestamp: '2026-04-01 13:55:10',
severity: 'medium',
stackTrace: 'SocketException: OS Error: Connection timed out, errno = 110, address = 10.0.2.2, port = 54332'
},
{
id: 4,
message: 'UI Thread blocking > 500ms',
village: 'Beji',
version: 'v1.1.2',
timestamp: '2026-04-01 13:40:00',
severity: 'low',
stackTrace: 'ANR (Application Not Responding) detected in main thread.'
},
]
export function ErrorDataTable() {
const [opened, { open, close }] = useDisclosure(false)
const [selectedError, setSelectedError] = useState<any>(null)
const handleRowClick = (error: any) => {
setSelectedError(error)
open()
}
const getSeverityColor = (sev: string) => {
switch(sev) {
case 'critical': return 'red'
case 'high': return 'orange'
case 'medium': return 'yellow'
default: return 'gray'
}
}
return (
<>
<Paper withBorder radius="2xl" className="glass overflow-hidden">
<Box p="xl" style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.08)' }}>
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon variant="light" color="red" size="lg" radius="md">
<TbBug size={20} />
</ThemeIcon>
<Text fw={700}>LATEST ERROR REPORTS</Text>
</Group>
<Button variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
View All Reports
</Button>
</Group>
</Box>
<ScrollArea>
<Table verticalSpacing="md" highlightOnHover className="data-table">
<Table.Thead bg="rgba(0,0,0,0.1)">
<Table.Tr>
<Table.Th px="xl">Error Message</Table.Th>
<Table.Th>Village</Table.Th>
<Table.Th>App Version</Table.Th>
<Table.Th>Timestamp</Table.Th>
<Table.Th pr="xl">Severity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockErrors.map((error) => (
<Table.Tr
key={error.id}
onClick={() => handleRowClick(error)}
style={{ cursor: 'pointer' }}
>
<Table.Td px="xl">
<Text size="sm" fw={600} lineClamp={1}>{error.message}</Text>
</Table.Td>
<Table.Td>
<Badge variant="dot" color="brand-blue" radius="sm">{error.village}</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" fw={700} c="dimmed">{error.version}</Text>
</Table.Td>
<Table.Td>
<Group gap={6}>
<TbHistory size={12} color="gray" />
<Text size="xs" c="dimmed">{error.timestamp}</Text>
</Group>
</Table.Td>
<Table.Td pr="xl">
<Badge color={getSeverityColor(error.severity)} variant="light" size="sm">
{error.severity.toUpperCase()}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
<Drawer
opened={opened}
onClose={close}
position="right"
size="md"
title={
<Group gap="xs">
<TbMessageReport color="#ef4444" size={24} />
<Title order={4}>Error Investigation</Title>
</Group>
}
styles={{
header: { padding: '24px', borderBottom: '1px solid rgba(255,255,255,0.1)' },
content: { background: 'rgba(15, 23, 42, 0.95)', backdropFilter: 'blur(12px)' }
}}
>
{selectedError && (
<Stack p="lg" gap="xl">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
<Text fw={700} size="lg" color="red">{selectedError.message}</Text>
</Box>
<SimpleGrid cols={2} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>VILLAGE</Text>
<Text fw={600}>{selectedError.village}</Text>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
<Badge variant="outline">{selectedError.version}</Badge>
</Box>
</SimpleGrid>
<Divider opacity={0.1} />
<Box>
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
<Paper p="md" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
<Code block color="red" bg="transparent" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{selectedError.stackTrace}
</Code>
</Paper>
</Box>
<Group justify="flex-end" mt="xl">
<Button variant="light" color="gray" onClick={close}>Dismiss</Button>
<Button variant="gradient" gradient={{ from: 'red', to: 'orange' }}>Assign Technician</Button>
</Group>
</Stack>
)}
</Drawer>
</>
)
}
import { SimpleGrid, ThemeIcon } from '@mantine/core'

View File

@@ -0,0 +1,66 @@
import { Card, Group, Text, ThemeIcon, Stack } from '@mantine/core'
import { IconType } from 'react-icons'
interface StatsCardProps {
title: string
value: string | number
description?: string
icon: IconType
color?: string
trend?: {
value: string
positive: boolean
}
}
export function StatsCard({ title, value, description, icon: Icon, color, trend }: StatsCardProps) {
return (
<Card
withBorder
padding="lg"
radius="xl"
className="premium-card"
styles={(theme) => ({
root: {
backgroundColor: theme.colors.dark[6],
borderColor: 'rgba(255,255,255,0.05)',
},
})}
>
<Group justify="space-between" mb="xs">
<ThemeIcon
size={42}
radius="md"
variant="light"
color={color || 'brand-blue'}
styles={{
root: {
background: 'rgba(124, 58, 237, 0.1)',
},
}}
>
<Icon size={22} />
</ThemeIcon>
{trend && (
<Text size="xs" fw={700} c={trend.positive ? 'teal' : 'red'}>
{trend.positive ? '+' : ''}{trend.value}%
</Text>
)}
</Group>
<Stack gap={0}>
<Text size="sm" c="dimmed" fw={600} style={{ letterSpacing: '0.02em', textTransform: 'uppercase' }}>
{title}
</Text>
<Text size="2xl" fw={800} mt={4}>
{value}
</Text>
{description && (
<Text size="xs" c="dimmed" mt={4}>
{description}
</Text>
)}
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,96 @@
import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge } from '@mantine/core'
import { IconType } from 'react-icons'
import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb'
interface SummaryCardProps {
title: string
value: string | number
icon: IconType
color?: string
trend?: {
value: string
positive: boolean
}
progress?: {
value: number
label: string
}
isError?: boolean
}
export function SummaryCard({
title,
value,
icon: Icon,
color = 'brand-blue',
trend,
progress,
isError
}: SummaryCardProps) {
return (
<Card
withBorder
padding="xl"
radius="2xl"
className="glass"
styles={(theme) => ({
root: {
backgroundColor: isError && Number(value) > 0 ? 'rgba(239, 68, 68, 0.05)' : 'rgba(30, 41, 59, 0.4)',
borderColor: isError && Number(value) > 10 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(255, 255, 255, 0.08)',
transition: 'transform 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
}
}
})}
>
<Group justify="space-between" mb="md">
<ThemeIcon
size={48}
radius="lg"
variant="light"
color={isError ? 'red' : color}
>
<Icon size={26} />
</ThemeIcon>
{trend && (
<Badge
variant="light"
color={trend.positive ? 'teal' : 'red'}
leftSection={trend.positive ? <TbTrendingUp size={14} /> : <TbTrendingDown size={14} />}
>
{trend.value}
</Badge>
)}
</Group>
<Stack gap={2}>
<Text size="xs" fw={700} c="dimmed" style={{ letterSpacing: '0.05em' }}>
{title.toUpperCase()}
</Text>
<Text size="2.4rem" fw={900} style={{ fontFamily: 'Outfit, sans-serif', letterSpacing: '-1px' }}>
{value}
</Text>
</Stack>
{progress && (
<Box mt="md">
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600}>{progress.label}</Text>
<Text size="xs" fw={700}>{progress.value}%</Text>
</Group>
<Progress
value={progress.value}
color={color}
size="sm"
radius="xl"
styles={{ section: { transition: 'width 1s ease' } }}
/>
</Box>
)}
</Card>
)
}
import { Box } from '@mantine/core'

View File

@@ -0,0 +1,49 @@
import { IconType } from 'react-icons'
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard } from 'react-icons/tb'
export interface MenuItem {
value: string
label: string
icon: IconType
to: string
}
export interface AppConfig {
id: string
name: string
menus: MenuItem[]
}
export const APP_CONFIGS: Record<string, AppConfig> = {
'desa-plus': {
id: 'desa-plus',
name: 'Desa+',
menus: [
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/desa-plus' },
{ 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: 'manage', label: 'Manage', icon: TbSettings, to: '/apps/desa-plus/manage' },
],
},
'e-commerce': {
id: 'e-commerce',
name: 'E-Commerce (Example)',
menus: [
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/e-commerce' },
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/e-commerce/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/e-commerce/errors' },
{ value: 'orders', label: 'Orders', icon: TbShoppingCart, to: '/apps/e-commerce/orders' },
{ value: 'products', label: 'Products', icon: TbPackage, to: '/apps/e-commerce/products' },
{ value: 'payments', label: 'Payments', icon: TbCreditCard, to: '/apps/e-commerce/payments' },
],
},
'fitness-app': {
id: 'fitness-app',
name: 'Fitness App (Example)',
menus: [
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/fitness-app' },
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/fitness-app/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/fitness-app/errors' },
],
},
}

View File

@@ -0,0 +1,175 @@
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Accordion,
ThemeIcon,
TextInput,
Select,
Code,
Box,
Button,
SimpleGrid,
} from '@mantine/core'
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
TbAlertTriangle,
TbBug,
TbDeviceDesktop,
TbDeviceMobile,
TbSearch,
TbFilter,
TbCircleCheck,
TbUserCheck
} from 'react-icons/tb'
export const Route = createFileRoute('/apps/$appId/errors')({
component: AppErrorsPage,
})
const mockErrors = [
{
id: 1,
title: 'NullPointerException: village_id is null',
message: 'Occurred during background sync with central server.',
version: '2.4.1',
device: 'PC Admin (Windows 10)',
time: '2 mins ago',
severity: 'critical',
users: 24,
frequency: 145,
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)'
},
{
id: 2,
title: 'SocketTimeoutException: Connection reset by peer',
message: 'Failed to upload document: surat_kematian_01.pdf',
version: '2.4.0',
device: 'Android Tablet (Samsung Tab A8)',
time: '15 mins ago',
severity: 'high',
users: 5,
frequency: 12,
stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)'
},
{
id: 3,
title: 'SQLiteException: no such column: village_id',
message: 'Failed to query local village profile database.',
version: '2.4.1',
device: 'PC Admin (Windows 7)',
time: '1 hour ago',
severity: 'medium',
users: 2,
frequency: 4,
stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)'
},
]
function AppErrorsPage() {
const { appId } = useParams({ from: '/apps/$appId/errors' })
return (
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>Error Reporting Center</Title>
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
</Stack>
<Button variant="light" color="red" leftSection={<TbBug size={16} />}>
Export Logs
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput
placeholder="Search error message, village, or stack trace..."
leftSection={<TbSearch size={16} />}
radius="md"
/>
<Select
placeholder="Severity"
data={['Critical', 'High', 'Medium', 'Low']}
leftSection={<TbFilter size={16} />}
radius="md"
clearable
/>
</Group>
<Accordion variant="separated" radius="xl">
{mockErrors.map((error) => (
<Accordion.Item
key={error.id}
value={error.id.toString()}
style={{ border: '1px solid rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)', marginBottom: '12px' }}
>
<Accordion.Control>
<Group wrap="nowrap">
<ThemeIcon
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
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}>{error.title}</Text>
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs">
{error.severity.toUpperCase()}
</Badge>
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">{error.time} v{error.version}</Text>
<Group gap={4} visibleFrom="sm">
<TbUserCheck size={12} color="gray" />
<Text size="xs" c="dimmed">{error.users} Users Affected</Text>
</Group>
</Group>
</Box>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md" py="xs">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
<Text size="sm" fw={500}>{error.message}</Text>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
<Text size="xs" fw={500}>{error.device}</Text>
</Group>
</Box>
</SimpleGrid>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Paper p="sm" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
<Code block color="red" bg="transparent" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px' }}>
{error.stackTrace}
</Code>
</Paper>
</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" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Paper>
</Stack>
)
}

View File

@@ -0,0 +1,125 @@
import {
Badge,
Button,
Card,
Group,
SimpleGrid,
Stack,
Text,
Title,
Paper,
Box,
ThemeIcon,
Select,
ActionIcon,
Container,
Divider,
} from '@mantine/core'
import { createFileRoute, Link, useParams } from '@tanstack/react-router'
import {
TbUsers,
TbActivity,
TbRefresh,
TbAlertTriangle,
TbCalendar,
TbFilter,
TbChevronRight,
TbArrowUpRight,
TbBuildingCommunity,
TbVersions
} from 'react-icons/tb'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
export const Route = createFileRoute('/apps/$appId/')({
component: AppOverviewPage,
})
function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' })
const isDesaPlus = appId === 'desa-plus'
return (
<Stack gap="xl">
{/* 🔝 HEADER SECTION */}
<Paper withBorder p="lg" radius="2xl" className="glass">
<Group justify="space-between">
<Stack gap={0}>
<Title order={2} className="gradient-text" style={{ fontSize: '1.8rem' }}>Overview</Title>
<Group gap="xs" mt={4}>
<Badge variant="light" size="lg" radius="sm" color="brand-blue" leftSection={<TbBuildingCommunity size={14} />}>
APP: {isDesaPlus ? 'DESA+' : appId.toUpperCase()}
</Badge>
<Text size="xs" c="dimmed" fw={600}>LAST UPDATED: JUST NOW</Text>
</Group>
</Stack>
<Group gap="md">
<Select
placeholder="Date Range"
data={['Today', '7 Days', '30 Days']}
defaultValue="Today"
leftSection={<TbCalendar size={16} />}
radius="md"
w={140}
/>
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbRefresh size={20} />
</ActionIcon>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED' }}
radius="md"
leftSection={<TbFilter size={18} />}
>
Add Filter
</Button>
</Group>
</Group>
</Paper>
{/* 📊 1. SUMMARY CARDS */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SummaryCard
title="Active Version"
value="v1.2.0"
icon={TbVersions}
color="brand-blue"
progress={{ value: 92, label: 'User Adoption' }}
/>
<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"
progress={{ value: 98, label: 'Integration Health' }}
/>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
{/* 📈 📊 2 & 3. CHARTS GRID */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart />
<VillageComparisonBarChart />
</SimpleGrid>
{/* 🐞 4. LATEST ERROR REPORTS */}
<ErrorDataTable />
</Stack>
)
}

View File

@@ -0,0 +1,123 @@
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Table,
TextInput,
Select,
ActionIcon,
Tooltip,
Avatar,
Code,
Button
} from '@mantine/core'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
export const Route = createFileRoute('/apps/$appId/logs')({
component: AppLogsPage,
})
const mockLogs = [
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
]
function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' })
const isDesaPlus = appId === 'desa-plus'
return (
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title>
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text>
</Stack>
<Group gap="xs">
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button>
</Group>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput
placeholder="Search activity, village, or operator..."
leftSection={<TbSearch size={16} />}
radius="md"
/>
<Select
placeholder="All Service Types"
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
leftSection={<TbFilter size={16} />}
radius="md"
clearable
/>
</Group>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Village / Instance</Table.Th>
<Table.Th>Activity Name</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockLogs.map((log) => (
<Table.Tr key={log.id}>
<Table.Td>
<Badge
variant="light"
color={
log.type === 'DOCUMENT' ? 'blue' :
log.type === 'FINANCE' ? 'teal' :
log.type === 'SYNC' ? 'orange' : 'gray'
}
size="xs"
>
{log.type}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{log.village}</Text>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="transparent" fw={800} style={{ fontSize: '11px' }}>{log.activity}</Code>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Avatar size="xs" radius="xl" color="brand-blue">{log.operator[0]}</Avatar>
<Text size="xs" fw={500}>{log.operator}</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{log.time}</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
variant="dot"
color={log.status === 'SUCCESS' ? 'teal' : 'orange'}
>
{log.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
)
}

View File

@@ -0,0 +1,278 @@
import { useState } from 'react'
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Table,
Button,
ActionIcon,
TextInput,
Select,
Tooltip,
SimpleGrid,
Modal,
Avatar,
Box,
NumberInput,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbPencil,
TbTrash,
TbUserPlus,
TbCircleCheck,
TbRefresh,
TbUser,
TbBuildingCommunity,
} from 'react-icons/tb'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/apps/$appId/manage')({
component: AppManagePage,
})
const mockDevelopers = [
{ value: 'john-doe', label: 'John Doe', avatar: null },
{ value: 'amel', label: 'Amel', avatar: null },
{ value: 'jane-smith', label: 'Jane Smith', avatar: null },
{ value: 'rahmat', label: 'Rahmat Hidayat', avatar: null },
]
function AppManagePage() {
const { appId } = useParams({ from: '/apps/$appId' })
const [initModalOpened, { open: openInit, close: closeInit }] = useDisclosure(false)
const [assignModalOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false)
const [selectedVillage, setSelectedVillage] = useState<any>(null)
const isDesaPlus = appId === 'desa-plus'
const mockVillages = [
{ id: 1, name: 'Sukatani', kecamatan: 'Tapos', population: 4500, status: 'fully integrated', developer: 'John Doe', lastUpdate: '2 mins ago' },
{ id: 2, name: 'Sukamaju', kecamatan: 'Cilodong', population: 3800, status: 'sync active', developer: 'Amel', lastUpdate: '15 mins ago' },
{ id: 3, name: 'Cikini', kecamatan: 'Menteng', population: 2100, status: 'sync pending', developer: 'Jane Smith', lastUpdate: '-' },
{ id: 4, name: 'Bojong Gede', kecamatan: 'Bojong Gede', population: 6700, status: 'fully integrated', developer: 'Rahmat', lastUpdate: '1 hour ago' },
]
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">General Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl">
{/* Metrics Row */}
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="lg">
<StatsCard
title="Total Integrations"
value={140}
icon={TbBuildingCommunity}
color="brand-blue"
trend={{ value: '12%', positive: true }}
/>
<StatsCard
title="Daily Sync Rate"
value="94.2%"
icon={TbRefresh}
color="teal"
trend={{ value: '2.5%', positive: true }}
/>
<StatsCard
title="Avg. Sync Delay"
value="45s"
icon={TbRefresh}
color="orange"
/>
<StatsCard
title="Pending Documents"
value={124}
icon={TbUser}
color="red"
/>
</SimpleGrid>
<Group justify="space-between" align="flex-end">
<Stack gap={0}>
<Title order={3}>Village Deployment Center</Title>
<Text size="sm" c="dimmed">Monitor and configure **Desa+** village instances across all districts.</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openInit}
>
Initialize New Village
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md">
<TextInput
placeholder="Search village or district..."
leftSection={<TbSearch size={16} />}
style={{ flex: 1 }}
radius="md"
/>
</Group>
<Table className="data-table" verticalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Village Profile</Table.Th>
<Table.Th>District</Table.Th>
<Table.Th>Integration Status</Table.Th>
<Table.Th>Lead Developer</Table.Th>
<Table.Th>Last Sync</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockVillages.map((village) => (
<Table.Tr key={village.id}>
<Table.Td>
<Stack gap={0}>
<Text fw={700} size="sm">{village.name}</Text>
<Text size="xs" c="dimmed">{village.population.toLocaleString()} Residents</Text>
</Stack>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{village.kecamatan}</Text>
</Table.Td>
<Table.Td>
<Badge
color={
village.status === 'fully integrated' ? 'teal' :
village.status === 'sync active' ? 'brand-blue' : 'orange'
}
variant={village.status === 'sync pending' ? 'outline' : 'light'}
leftSection={village.status !== 'sync pending' && <TbCircleCheck size={12} />}
radius="sm"
style={{ textTransform: 'uppercase', fontVariant: 'small-caps' }}
>
{village.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Avatar size="xs" radius="xl" color="brand-blue" src={null} />
<Text size="sm">{village.developer}</Text>
<ActionIcon
variant="subtle"
size="xs"
onClick={() => { setSelectedVillage(village); openAssign(); }}
>
<TbUserPlus size={12} />
</ActionIcon>
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" fw={500} c={village.lastUpdate === '-' ? 'dimmed' : 'teal'}>
{village.lastUpdate}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
{village.status === 'sync pending' && (
<Button variant="light" size="compact-xs" color="blue" onClick={openInit}>
START SYNC
</Button>
)}
<Tooltip label="Village Settings">
<ActionIcon variant="light" size="sm" color="gray">
<TbPencil size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Unlink Village">
<ActionIcon variant="light" size="sm" color="red">
<TbTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
{/* MODALS */}
<Modal
opened={initModalOpened}
onClose={closeInit}
title={<Title order={4}>Desa+ Instance Initialization</Title>}
radius="xl"
centered
padding="xl"
>
<Stack gap="md">
<SimpleGrid cols={2}>
<TextInput label="Village Name" placeholder="e.g. Sukatani" radius="md" required />
<TextInput label="Kecamatan" placeholder="e.g. Tapos" radius="md" required />
</SimpleGrid>
<Group grow>
<Select
label="Population Data Source"
placeholder="Select source..."
data={['SIAK Terpusat', 'BPS Proyeksi', 'Manual Upload']}
radius="md"
/>
<NumberInput label="Target Residents" placeholder="1000" radius="md" />
</Group>
<Box>
<Text size="xs" fw={700} c="dimmed" mb="xs">INITIAL SYNC MODULES</Text>
<Group gap="xs">
<Badge variant="outline" color="blue">PENDUDUK</Badge>
<Badge variant="outline" color="teal">KEUANGAN</Badge>
<Badge variant="outline" color="brand-purple">PELAYANAN</Badge>
<Badge variant="outline" color="orange">APBDes</Badge>
</Group>
</Box>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeInit}>Cancel</Button>
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Deploy Instance</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={assignModalOpened}
onClose={closeAssign}
title={<Title order={4}>Assign Lead Developer</Title>}
radius="xl"
centered
padding="xl"
>
<Stack gap="md">
<Text size="sm">Assign a dedicated reviewer for <b>{selectedVillage?.name}</b> instance stability.</Text>
<Select
label="Technical Lead"
placeholder="Search developer..."
data={mockDevelopers}
leftSection={<TbUser size={16} />}
radius="md"
searchable
/>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeAssign}>Cancel</Button>
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Set Lead</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}

View File

@@ -0,0 +1,120 @@
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Table,
TextInput,
Button,
ActionIcon,
Tooltip,
SimpleGrid
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbFilter, TbEye, TbReceipt, TbTruckDelivery, TbCreditCard, TbTrendingUp } from 'react-icons/tb'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/apps/$appId/orders')({
component: OrdersPage,
})
const mockOrders = [
{ id: 'ORD-9921', customer: 'John Doe', amount: 'Rp 1.250.000', status: 'PAID', time: '2 mins ago', method: 'BCA Virtual Account' },
{ id: 'ORD-9922', customer: 'Jane Smith', amount: 'Rp 450.000', status: 'PENDING', time: '15 mins ago', method: 'OVO' },
{ id: 'ORD-9923', customer: 'Rahmat', amount: 'Rp 2.100.000', status: 'SHIPPING', time: '1 hour ago', method: 'Mandiri Transfer' },
{ id: 'ORD-9924', customer: 'Amel', amount: 'Rp 750.000', status: 'REFUNDED', time: '2 hours ago', method: 'GoPay' },
{ id: 'ORD-9925', customer: 'Siti', amount: 'Rp 1.100.000', status: 'PAID', time: '4 hours ago', method: 'Credit Card' },
]
function OrdersPage() {
return (
<Stack gap="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Daily Revenue" value="Rp 12.8M" icon={TbTrendingUp} color="teal" trend={{ value: '8.4%', positive: true }} />
<StatsCard title="Active Orders" value="142" icon={TbReceipt} color="brand-blue" />
<StatsCard title="On Delivery" value="48" icon={TbTruckDelivery} color="orange" />
</SimpleGrid>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>Orders Tracking</Title>
<Text size="sm" c="dimmed">Detailed transaction and shipment tracking center.</Text>
</Stack>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput
placeholder="Search Order ID or Customer..."
leftSection={<TbSearch size={16} />}
radius="md"
/>
<Badge component="div" variant="light" size="xl" radius="md" style={{ display: 'flex', alignItems: 'center', height: '36px' }}>
<TbFilter size={16} style={{ marginRight: 8 }} /> Filter by Status
</Badge>
</Group>
<Table className="data-table" verticalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Order ID</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Amount</Table.Th>
<Table.Th>Payment Method</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockOrders.map((order) => (
<Table.Tr key={order.id}>
<Table.Td>
<Group gap="xs">
<TbReceipt size={14} color="gray" />
<Text fw={600} size="sm">{order.id}</Text>
</Group>
<Text size="xs" c="dimmed">{order.time}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{order.customer}</Text>
</Table.Td>
<Table.Td>
<Text fw={700} size="sm" color="brand-blue">{order.amount}</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
<TbCreditCard size={12} color="gray" />
<Text size="xs" c="dimmed">{order.method}</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge
radius="sm"
color={
order.status === 'PAID' ? 'teal' :
order.status === 'PENDING' ? 'orange' :
order.status === 'SHIPPING' ? 'brand-blue' : 'red'
}
variant={order.status === 'PENDING' ? 'outline' : 'light'}
>
{order.status}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip label="View Details">
<ActionIcon variant="light" size="sm" color="blue">
<TbEye size={14} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
)
}

View File

@@ -0,0 +1,124 @@
import {
Stack,
Title,
Text,
Paper,
Group,
Badge,
Table,
ThemeIcon,
SimpleGrid,
Box,
Tooltip
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TbCreditCard, TbCheck, TbAlertCircle, TbWallet, TbHistory, TbArrowUpRight, TbCircleCheck, TbRefresh } from 'react-icons/tb'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/apps/$appId/payments')({
component: PaymentsPage,
})
const mockPayments = [
{ id: 'TRX-8412', method: 'Credit Card (Visa)', amount: 'Rp 2.450.000', status: 'SUCCESS', date: '2026-04-01 14:30', gateway: 'Stripe' },
{ id: 'TRX-8413', method: 'BCA Virtual Account', amount: 'Rp 1.150.000', status: 'PENDING', date: '2026-04-01 14:28', gateway: 'Midtrans' },
{ id: 'TRX-8414', method: 'OVO / E-Wallet', amount: 'Rp 450.000', status: 'SUCCESS', date: '2026-04-01 14:25', gateway: 'Midtrans' },
{ id: 'TRX-8415', method: 'ShopeePay', amount: 'Rp 890.000', status: 'FAILED', date: '2026-04-01 14:20', gateway: 'Midtrans' },
{ id: 'TRX-8416', method: 'Mandiri Transfer', amount: 'Rp 3.200.000', status: 'SUCCESS', date: '2026-04-01 14:15', gateway: 'Bank Sync' },
]
const gateways = [
{ name: 'Stripe', status: 'Operational', color: 'teal' },
{ name: 'Midtrans', status: 'Operational', color: 'teal' },
{ name: 'Bank Sync', status: 'Slow Response', color: 'orange' },
]
function PaymentsPage() {
return (
<Stack gap="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Volume" value="Rp 842.5M" icon={TbWallet} color="brand-blue" trend={{ value: '12.4%', positive: true }} />
<StatsCard title="Success Rate" value="98.2%" icon={TbCircleCheck} color="teal" />
<StatsCard title="Refunds (30d)" value="Rp 4.2M" icon={TbHistory} color="red" />
</SimpleGrid>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>Payment Gateway Health</Title>
<Text size="sm" c="dimmed">Real-time status monitoring for integrated payment providers.</Text>
</Stack>
</Group>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
{gateways.map((gw) => (
<Paper key={gw.name} withBorder radius="xl" p="md" className="glass">
<Group justify="space-between">
<Group gap="xs">
<ThemeIcon variant="light" color={gw.color} radius="md" size="lg">
<TbRefresh size={24} />
</ThemeIcon>
<Text fw={600} size="sm">{gw.name}</Text>
</Group>
<Badge color={gw.color} variant="dot">{gw.status}</Badge>
</Group>
</Paper>
))}
</SimpleGrid>
<Stack gap="xs">
<Title order={4} mt="md">Recent Transactions</Title>
<Paper withBorder radius="2xl" className="glass" p="md">
<Table className="data-table" verticalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Transaction ID</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>Gateway</Table.Th>
<Table.Th>Amount</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Timestamp</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockPayments.map((trx) => (
<Table.Tr key={trx.id}>
<Table.Td>
<Group gap="xs">
<TbArrowUpRight size={14} color="gray" />
<Text fw={600} size="sm">{trx.id}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<TbCreditCard size={14} color="gray" />
<Text size="sm">{trx.method}</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="default" radius="sm">{trx.gateway}</Badge>
</Table.Td>
<Table.Td>
<Text fw={700} size="sm" color="brand-blue">{trx.amount}</Text>
</Table.Td>
<Table.Td>
<Badge
color={trx.status === 'SUCCESS' ? 'teal' : trx.status === 'PENDING' ? 'orange' : 'red'}
variant={trx.status === 'SUCCESS' ? 'light' : 'outline'}
leftSection={trx.status === 'SUCCESS' ? <TbCheck size={12} /> : <TbAlertCircle size={12} />}
radius="sm"
>
{trx.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{trx.date}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,111 @@
import {
Stack,
Title,
Text,
SimpleGrid,
Card,
Group,
Badge,
Button,
ThemeIcon,
Box,
Progress,
ActionIcon,
Tooltip
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TbPlus, TbPackage, TbAlertTriangle, TbTrendingUp, TbPencil, TbTrash, TbArchive } from 'react-icons/tb'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/apps/$appId/products')({
component: ProductsPage,
})
const mockProducts = [
{ id: 1, name: 'Premium Wireless Headphones', price: 'Rp 2.450.000', stock: 12, sales: 145, status: 'IN_STOCK' },
{ id: 2, name: 'Mechanical Keyboard RGB', price: 'Rp 1.150.000', stock: 4, sales: 89, status: 'LOW_STOCK' },
{ id: 3, name: 'Ultra-wide Monitor 34"', price: 'Rp 8.900.000', stock: 2, sales: 34, status: 'LOW_STOCK' },
{ id: 4, name: 'Ergonomic Gaming Chair', price: 'Rp 3.200.000', stock: 0, sales: 56, status: 'OUT_OF_STOCK' },
{ id: 5, name: 'USB-C Hub Multiport', price: 'Rp 450.000', stock: 45, sales: 231, status: 'IN_STOCK' },
{ id: 6, name: 'Webcam 4K Ultra HD', price: 'Rp 1.750.000', stock: 18, sales: 67, status: 'IN_STOCK' },
]
function ProductsPage() {
return (
<Stack gap="xl">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Inventory" value="452 Units" icon={TbPackage} color="brand-blue" />
<StatsCard title="Low Stock Alerts" value="8 Items" icon={TbAlertTriangle} color="orange" />
<StatsCard title="Top Performing" value="3 items" icon={TbTrendingUp} color="teal" />
</SimpleGrid>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>Product Catalog</Title>
<Text size="sm" c="dimmed">Inventory management and performance monitoring center.</Text>
</Stack>
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} leftSection={<TbPlus size={18} />} radius="md">
Add New Product
</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
{mockProducts.map((product) => (
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
<Card.Section>
<Box h={160} style={{ background: 'rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
<TbArchive size={34} />
</ThemeIcon>
</Box>
</Card.Section>
<Stack gap="xs" mt="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600} size="sm" truncate>{product.name}</Text>
<Badge
size="xs"
color={product.status === 'IN_STOCK' ? 'teal' : product.status === 'LOW_STOCK' ? 'orange' : 'red'}
variant="light"
>
{product.status.replace('_', ' ')}
</Badge>
</Group>
<Group justify="space-between" mt={4}>
<Text fw={700} size="lg" color="brand-blue">{product.price}</Text>
<Text size="xs" c="dimmed">{product.sales} Sales</Text>
</Group>
<Box mt="xs">
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed">Stock Level</Text>
<Text size="xs" fw={700}>{product.stock}/50</Text>
</Group>
<Progress
value={(product.stock / 50) * 100}
color={product.stock > 10 ? 'teal' : product.stock > 0 ? 'orange' : 'red'}
size="xs"
radius="xl"
/>
</Box>
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<Tooltip label="Edit Product">
<ActionIcon variant="light" size="sm" color="blue">
<TbPencil size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove">
<ActionIcon variant="light" size="sm" color="red">
<TbTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
)
}

View File

@@ -0,0 +1,45 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import {
Box,
Container,
Group,
Stack,
Text,
Title
} from '@mantine/core'
import { createFileRoute, Outlet, useNavigate, useParams } from '@tanstack/react-router'
export const Route = createFileRoute('/apps/$appId')({
component: AppDetailLayout,
})
function AppDetailLayout() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
// Format app ID for display (e.g., desa-plus -> Desa+)
const appName = appId
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
.replace('Plus', '+')
return (
<DashboardLayout>
<Container size="xl" py="lg">
<Stack gap="xl">
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title>
<Text c="dimmed" size="sm" fw={500}>Application ID: <span style={{ fontFamily: 'monospace' }}>{appId}</span></Text>
</Stack>
</Group>
<Box mt="md">
<Outlet />
</Box>
</Stack>
</Container>
</DashboardLayout>
)
}

View File

@@ -0,0 +1,59 @@
import { useQuery } from '@tanstack/react-query'
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TbPlus, TbSearch } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { AppCard } from '@/frontend/components/AppCard'
export const Route = createFileRoute('/apps/')({
component: AppsPage,
})
function AppsPage() {
const { data: apps, isLoading } = useQuery({
queryKey: ['apps'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
return (
<DashboardLayout>
<Container size="xl" py="lg">
<Stack gap="xl">
<Group justify="space-between" align="flex-end">
<Stack gap={0}>
<Title order={2} className="gradient-text">Applications</Title>
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
>
Add New Application
</Button>
</Group>
<Group>
<TextInput
placeholder="Search applications..."
leftSection={<TbSearch size={16} />}
style={{ flex: 1 }}
radius="md"
/>
</Group>
{isLoading ? (
<Loader size="xl" type="dots" mx="auto" mt="xl" />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{apps?.map((app: any) => (
<AppCard key={app.id} {...app} />
))}
</SimpleGrid>
)}
</Stack>
</Container>
</DashboardLayout>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/apps')({
component: () => <Outlet />,
})

View File

@@ -1,20 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import {
Avatar,
Badge,
Button,
Card,
Container,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
Paper,
Table,
Loader,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
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'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
@@ -33,62 +36,140 @@ export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
})
const stats = [
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
const recentErrors = [
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
]
function DashboardPage() {
const { data } = useSession()
const logout = useLogout()
const user = data?.user
const { data: sessionData } = useSession()
const user = sessionData?.user
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: () => fetch('/api/dashboard/stats').then((r) => r.json()),
})
const { data: apps, isLoading: appsLoading } = useQuery({
queryKey: ['apps'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Title order={2}>Dashboard</Title>
<Button
variant="light"
color="red"
leftSection={<TbLogout size={16} />}
onClick={() => logout.mutate()}
loading={logout.isPending}
>
Logout
</Button>
</Group>
<Paper withBorder p="lg" radius="md">
<Group>
<Avatar color="blue" radius="xl" size="lg">
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div>
<Group gap="xs">
<Text fw={500}>{user?.name}</Text>
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
</Group>
<Text c="dimmed" size="sm">{user?.email}</Text>
</div>
<DashboardLayout>
<Container size="xl" py="lg">
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={2} className="gradient-text">Overview Dashboard</Title>
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbApps size={18} />}
radius="md"
component={Link}
to="/apps"
>
Manage All Apps
</Button>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
<ThemeIcon variant="light" color={stat.color} size="sm">
<stat.icon size={14} />
</ThemeIcon>
</Group>
<Text fw={700} size="xl">{stat.value}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Container>
{statsLoading ? (
<Loader size="xl" type="dots" mx="auto" mt="xl" />
) : (
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<StatsCard
title="Total Applications"
value={stats?.totalApps || 0}
icon={TbApps}
color="brand-blue"
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
/>
<StatsCard
title="New Errors"
value={stats?.newErrors || 0}
icon={TbMessageReport}
color="brand-purple"
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
/>
<StatsCard
title="Active Users"
value={stats?.activeUsers || 0}
icon={TbUsers}
color="teal"
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
/>
</SimpleGrid>
)}
<Group justify="space-between" mt="md">
<Title order={3}>Registered Applications</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
View Report
</Button>
</Group>
{appsLoading ? (
<Loader size="xl" type="dots" mx="auto" />
) : (
<SimpleGrid cols={{ base: 1, md: 3 }}>
{apps?.map((app: any) => (
<AppCard key={app.id} {...app} />
))}
</SimpleGrid>
)}
<Group justify="space-between" mt="md">
<Title order={3}>Recent Error Reports</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
View All Errors
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Table className="data-table" verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Application</Table.Th>
<Table.Th>Error Message</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Time</Table.Th>
<Table.Th>Severity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recentErrors.map((error) => (
<Table.Tr key={error.id}>
<Table.Td>
<Text fw={600} size="sm">{error.app}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{error.message}</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="gray">v{error.version}</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{error.time}</Text>
</Table.Td>
<Table.Td>
<Badge
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
variant="dot"
>
{error.severity.toUpperCase()}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
</Container>
</DashboardLayout>
)
}

View File

@@ -17,7 +17,7 @@ import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
import { useLogin } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/login')({
validateSearch: (search: Record<string, unknown>) => ({
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
error: (search.error as string) || undefined,
}),
beforeLoad: async ({ context }) => {

View File

@@ -0,0 +1,222 @@
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Group,
Stack,
Table,
Text,
TextInput,
Title,
Paper,
Tabs,
Avatar,
SimpleGrid,
ThemeIcon,
List,
Box,
Divider,
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbPencil,
TbTrash,
TbUserCheck,
TbShieldCheck,
TbAccessPoint,
TbCircleCheck,
TbClock,
TbApps,
} from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/settings')({
component: SettingsPage,
})
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 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 SettingsPage() {
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">Settings</Title>
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
</Stack>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
</SimpleGrid>
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
<Tabs.List>
<Tabs.Tab value="users" leftSection={<TbUserCheck size={16} />}>User Management</Tabs.Tab>
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Management</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="xl">
<Stack gap="md">
<Group justify="space-between">
<TextInput
placeholder="Search users..."
leftSection={<TbSearch size={16} />}
radius="md"
w={350}
variant="filled"
/>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
>
Add New User
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
<Table className="data-table" verticalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name & Contact</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>App Access</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockUsers.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
<Text size="xs" fw={500}>{user.status}</Text>
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={4}>
<TbApps size={12} color="gray" />
<Text size="xs" fw={500}>{user.apps}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="light" size="sm" color="blue">
<TbPencil size={14} />
</ActionIcon>
<ActionIcon variant="light" size="sm" color="red">
<TbTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="roles" pt="xl">
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
{roles.map((role) => (
<Card key={role.name} withBorder radius="2xl" padding="xl" className="glass">
<Stack gap="md">
<Group justify="space-between">
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
<TbShieldCheck size={28} />
</ThemeIcon>
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
</Group>
<Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
</Stack>
<Divider />
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon color="teal" size={16} radius="xl">
<TbCircleCheck size={12} />
</ThemeIcon>
}
>
{role.permissions.map((p) => (
<List.Item key={p}>{p}</List.Item>
))}
</List>
<Button fullWidth variant="light" color={role.color} mt="md" radius="md">
Edit Permissions
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
</Tabs.Panel>
</Tabs>
</Stack>
</Container>
</DashboardLayout>
)
}

View File

@@ -1,187 +1,113 @@
@import '@mantine/core/styles.css';
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
/* Monitoring System Colors */
--blue-primary: #2563EB;
--purple-primary: #7C3AED;
--gradient-blue-purple: linear-gradient(135deg, var(--blue-primary) 0%, var(--purple-primary) 100%);
/* Backgrounds & Cards (Light Mode) */
--bg-light: #F8FAFC;
--card-light: #FFFFFF;
/* Backgrounds & Cards (Dark Mode) */
--bg-dark: #0F172A;
--card-dark: #1E293B;
/* Transitions */
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Base Resets */
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
font-family: var(--font-inter);
background-color: var(--bg-dark); /* Default to Dark Mode as per App.tsx */
color: #F8FAFC;
}
body {
margin: 0;
display: grid;
place-items: center;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
}
/* Custom Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.4);
}
/* Premium Dashboard Utilities */
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px; /* XL rounding for cards */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.gradient-text {
background: var(--gradient-blue-purple);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.gradient-bg {
background: var(--gradient-blue-purple);
}
.premium-card {
transition: var(--transition-smooth);
}
.premium-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -10px rgba(124, 58, 237, 0.3);
}
.sidebar-nav-item {
position: relative;
transition: var(--transition-smooth);
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
background: url("./logo.svg");
background-size: 256px;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
.sidebar-nav-item.active {
background: var(--gradient-blue-purple);
color: white;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
/* Responsive Table */
.data-table {
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
position: relative;
z-index: 1;
.data-table thead th {
background: rgba(124, 58, 237, 0.05);
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
padding: 12px 16px;
}
.logo-container {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
.data-table tbody tr {
transition: var(--transition-smooth);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 0.3s;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.bun-logo {
transform: scale(1.2);
}
.bun-logo:hover {
filter: drop-shadow(0 0 2em #fbf0dfaa);
}
.react-logo {
animation: spin 20s linear infinite;
}
.react-logo:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
code {
background-color: #1a1a1a;
padding: 0.2em 0.4em;
border-radius: 0.3em;
font-family: monospace;
}
.api-tester {
margin: 2rem auto 0;
width: 100%;
max-width: 600px;
text-align: left;
display: flex;
flex-direction: column;
gap: 1rem;
}
.endpoint-row {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1a1a1a;
padding: 0.75rem;
border-radius: 12px;
font: monospace;
border: 2px solid #fbf0df;
transition: 0.3s;
width: 100%;
box-sizing: border-box;
}
.endpoint-row:focus-within {
border-color: #f3d5a3;
}
.method {
background: #fbf0df;
color: #1a1a1a;
padding: 0.3rem 0.7rem;
border-radius: 8px;
font-weight: 700;
font-size: 0.9em;
appearance: none;
margin: 0;
width: min-content;
display: block;
flex-shrink: 0;
border: none;
}
.method option {
text-align: left;
}
.url-input {
width: 100%;
flex: 1;
background: 0;
border: 0;
color: #fbf0df;
font: 1em monospace;
padding: 0.2rem;
outline: 0;
}
.url-input:focus {
color: #fff;
}
.url-input::placeholder {
color: rgba(251, 240, 223, 0.4);
}
.send-button {
background: #fbf0df;
color: #1a1a1a;
border: 0;
padding: 0.4rem 1.2rem;
border-radius: 8px;
font-weight: 700;
transition: 0.1s;
cursor: var(--bun-cursor);
}
.send-button:hover {
background: #f3d5a3;
transform: translateY(-1px);
cursor: pointer;
}
.response-area {
width: 100%;
min-height: 120px;
background: #1a1a1a;
border: 2px solid #fbf0df;
border-radius: 12px;
padding: 0.75rem;
color: #fbf0df;
font: monospace;
resize: vertical;
box-sizing: border-box;
}
.response-area:focus {
border-color: #f3d5a3;
}
.response-area::placeholder {
color: rgba(251, 240, 223, 0.4);
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
.data-table tbody tr:hover {
background: rgba(124, 58, 237, 0.03);
}