7 Commits

Author SHA1 Message Date
a47d61e9af upd: tampilan detail desa 2026-04-04 12:10:36 +08:00
a245225aca upd: tampilan 2026-04-04 10:04:10 +08:00
416c623bec Merge pull request 'amalia/02-apr-26' (#2) from amalia/02-apr-26 into main
Reviewed-on: #2
2026-04-02 17:38:59 +08:00
0957a4d271 upd: fitur tampilan update 2026-04-02 17:37:35 +08:00
5a4128a157 upd: log global user 2026-04-02 14:33:16 +08:00
ac17e059c7 upd: tampilan dark dan light 2026-04-02 11:59:21 +08:00
5136342877 Merge pull request 'upd: tampilan' (#1) from amalia/01-apr-26 into main
Reviewed-on: #1
2026-04-02 10:31:36 +08:00
18 changed files with 1283 additions and 410 deletions

View File

@@ -4,38 +4,38 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const theme = createTheme({
const theme = createTheme({
primaryColor: 'brand-blue',
colors: {
'brand-blue': [
'#ebf2ff',
'#d6e4ff',
'#adc8ff',
'#85acff',
'#5c90ff',
'#2563eb', // Primary Blue
'#1e4fb8',
'#173b85',
'#102752',
'#09131f',
'#f0f9ff',
'#e0f2fe',
'#bae6fd',
'#7dd3fc',
'#38bdf8',
'#0ea5e9', // Primary Blue (Sky)
'#0284c7',
'#0369a1',
'#075985',
'#0c4a6e',
],
'brand-purple': [
'#f3ebff',
'#e7d6ff',
'#cfadff',
'#b785ff',
'#9f5cff',
'#7c3aed', // Primary Purple
'#632eb8',
'#4a2285',
'#311652',
'#180b1f',
'#faf5ff',
'#f3e8ff',
'#e9d5ff',
'#d8b4fe',
'#c084fc',
'#a855f7', // Primary Purple
'#9333ea',
'#7e22ce',
'#6b21a8',
'#581c87',
],
},
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
headings: {
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600',
fontWeight: '500', // Softer headings
},
})
@@ -59,8 +59,8 @@ declare module '@tanstack/react-router' {
export function App() {
return (
<>
<ColorSchemeScript defaultColorScheme="dark" />
<MantineProvider theme={theme} defaultColorScheme="dark" forceColorScheme="dark">
<ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="auto">
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>

View File

@@ -1,4 +1,4 @@
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box } from '@mantine/core'
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core'
import { Link } from '@tanstack/react-router'
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
@@ -13,6 +13,7 @@ interface AppCardProps {
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
<Card
@@ -22,12 +23,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
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',
backgroundColor: 'var(--mantine-color-body)',
borderColor: scheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, border-color 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 12px 24px -8px rgba(0, 0, 0, 0.4)',
boxShadow: theme.shadows.md,
borderColor: 'rgba(37, 99, 235, 0.3)',
},
},
@@ -45,7 +46,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</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>
<Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text>
</Stack>
</Group>
<Badge color={statusColor} variant="dot" size="sm">
@@ -53,7 +54,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</Badge>
</Group>
<Stack gap="md" mt="sm">
{/* <Stack gap="md" mt="sm">
<Box>
<Group justify="space-between" mb={6}>
<Group gap="xs">
@@ -69,13 +70,13 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
<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>
<Text size="xs" fw={700} c="dimmed">ERROR</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>
</Stack> */}
<Button
component={Link}
@@ -95,7 +96,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
}
}}
>
Center Intelligence
View
</Button>
</Card>
)

View File

@@ -1,5 +1,6 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus'
import {
ActionIcon,
AppShell,
Avatar,
Box,
@@ -14,6 +15,7 @@ import {
ThemeIcon
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import {
TbApps,
@@ -23,7 +25,11 @@ import {
TbDeviceMobile,
TbLogout,
TbSettings,
TbUserCircle
TbUserCircle,
TbSun,
TbMoon,
TbUser,
TbHistory
} from 'react-icons/tb'
interface DashboardLayoutProps {
@@ -31,7 +37,10 @@ interface DashboardLayoutProps {
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [opened, { toggle }] = useDisclosure()
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
const { toggleColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
const location = useLocation()
const navigate = useNavigate()
const { appId } = useParams({ strict: false }) as { appId?: string }
@@ -42,7 +51,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const globalNav = [
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Settings', icon: TbSettings, to: '/settings' },
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
{ label: 'Users', icon: TbUser, to: '/users' },
]
const activeApp = appId ? APP_CONFIGS[appId] : null
@@ -54,19 +64,21 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
navbar={{
width: 260,
breakpoint: 'sm',
collapsed: { mobile: !opened },
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="xl"
styles={(theme) => ({
main: {
backgroundColor: theme.colors.dark[7], // Dark mode background
backgroundColor: computedColorScheme === 'dark' ? theme.colors.dark[9] : theme.colors.gray[0],
transition: 'background-color 0.2s ease',
},
})}
>
<AppShell.Header px="xl">
<Group h="100%" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
<Group gap="xs">
<ThemeIcon
size={34}
@@ -88,6 +100,14 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Group>
<Group gap="md">
<ActionIcon
onClick={() => toggleColorScheme()}
variant="default"
size="lg"
aria-label="Toggle color scheme"
>
{computedColorScheme === 'dark' ? <TbSun size={18} /> : <TbMoon size={18} />}
</ActionIcon>
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<Avatar
@@ -149,7 +169,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
mb={"md"}
variant="filled"
styles={(theme) => ({
input: { border: '1px solid rgba(255,255,255,0.1)' }
input: { border: computedColorScheme === 'dark' ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)' }
})}
/>
}
@@ -198,7 +218,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<Box
p="md"
className="glass"
style={{ borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}
style={{ borderRadius: '12px', border: computedColorScheme === 'dark' ? '1px solid rgba(255,255,255,0.05)' : '1px solid rgba(0,0,0,0.05)' }}
>
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
<Group gap="xs">

View File

@@ -15,6 +15,7 @@ import {
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
const mockErrors = [
@@ -85,7 +86,7 @@ export function ErrorDataTable() {
</ThemeIcon>
<Text fw={700}>LATEST ERROR REPORTS</Text>
</Group>
<Button variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
<Button component={Link} to='/apps/desa-plus/errors' variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
View All Reports
</Button>
</Group>
@@ -148,8 +149,7 @@ export function ErrorDataTable() {
</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)' }
header: { padding: '24px', borderBottom: '1px solid var(--mantine-color-default-border)' },
}}
>
{selectedError && (
@@ -174,11 +174,9 @@ export function ErrorDataTable() {
<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>
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
{selectedError.stackTrace}
</Code>
</Box>
<Group justify="flex-end" mt="xl">

View File

@@ -22,8 +22,8 @@ export function StatsCard({ title, value, description, icon: Icon, color, trend
className="premium-card"
styles={(theme) => ({
root: {
backgroundColor: theme.colors.dark[6],
borderColor: 'rgba(255,255,255,0.05)',
backgroundColor: 'var(--mantine-color-body)',
borderColor: 'rgba(128,128,128,0.1)',
},
})}
>

View File

@@ -1,4 +1,4 @@
import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge } from '@mantine/core'
import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge, useComputedColorScheme } from '@mantine/core'
import { IconType } from 'react-icons'
import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb'
@@ -16,6 +16,8 @@ interface SummaryCardProps {
label: string
}
isError?: boolean
onClick?: () => void
children?: React.ReactNode
}
export function SummaryCard({
@@ -25,19 +27,29 @@ export function SummaryCard({
color = 'brand-blue',
trend,
progress,
isError
isError,
onClick,
children
}: SummaryCardProps) {
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
<Card
withBorder
padding="xl"
radius="2xl"
className="glass"
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
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',
backgroundColor: isError && Number(value) > 0
? (scheme === 'dark' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 241, 242, 1)') // light pink for error in light mode
: 'var(--mantine-color-body)',
borderColor: isError && Number(value) > 10
? 'rgba(239, 68, 68, 0.3)'
: scheme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)',
transition: 'transform 0.2s ease, background-color 0.2s ease, border-color 0.2s ease',
'&:hover': {
transform: 'translateY(-4px)',
}
@@ -89,6 +101,8 @@ export function SummaryCard({
/>
</Box>
)}
{children}
</Card>
)
}

View File

@@ -1,5 +1,5 @@
import { IconType } from 'react-icons'
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard } from 'react-icons/tb'
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard, TbBuilding } from 'react-icons/tb'
export interface MenuItem {
value: string
@@ -22,7 +22,7 @@ export const APP_CONFIGS: Record<string, AppConfig> = {
{ 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' },
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
],
},
'e-commerce': {

View File

@@ -106,7 +106,7 @@ function AppErrorsPage() {
<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' }}
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }}
>
<Accordion.Control>
<Group wrap="nowrap">
@@ -153,11 +153,9 @@ function AppErrorsPage() {
<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>
<Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
{error.stackTrace}
</Code>
</Box>
<Group justify="flex-end" pt="sm">

View File

@@ -1,36 +1,29 @@
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import {
Badge,
Button,
Card,
ActionIcon,
Group,
SimpleGrid,
Stack,
Text,
Title,
Paper,
Box,
ThemeIcon,
Select,
ActionIcon,
Container,
Divider,
Modal,
Button,
TextInput,
Switch,
Badge,
Textarea
} from '@mantine/core'
import { createFileRoute, Link, useParams } from '@tanstack/react-router'
import {
TbUsers,
TbActivity,
TbRefresh,
TbAlertTriangle,
TbCalendar,
TbFilter,
TbChevronRight,
TbArrowUpRight,
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import {
TbActivity,
TbAlertTriangle,
TbBuildingCommunity,
TbRefresh,
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,
@@ -38,71 +31,100 @@ export const Route = createFileRoute('/apps/$appId/')({
function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
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>
<>
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
<Stack gap="md">
<TextInput label="Active Version" defaultValue="v1.2.0" />
<TextInput label="Minimum Version" defaultValue="v1.0.0" />
<Textarea
label="Update Message"
placeholder="Enter release notes or update message..."
minRows={3}
autosize
/>
<Switch label="Maintenance Mode" description="Enable to put the app in maintenance mode for users." />
<Button fullWidth onClick={closeVersionModal}>Save Changes</Button>
</Stack>
</Modal>
<Group gap="md">
<Select
<Stack gap="xl">
{/* 🔝 HEADER SECTION */}
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */}
<Group justify="space-between">
<Stack gap={0}>
<Title order={3}>Overview</Title>
<Text size="sm" c="dimmed">Last updated: Just now</Text>
</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
/> */}
<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>
</Button> */}
</Group>
</Paper>
</Group>
{/* </Paper> */}
{/* 📊 1. SUMMARY CARDS */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SummaryCard
<SummaryCard
title="Active Version"
value="v1.2.0"
icon={TbVersions}
color="brand-blue"
progress={{ value: 92, label: 'User Adoption' }}
/>
<SummaryCard
onClick={openVersionModal}
>
<Group justify="space-between" mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">Min. Version</Text>
<Text size="sm" fw={600}>v1.0.0</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed">Maintenance</Text>
<Badge size="sm" color="gray" variant="light">False</Badge>
</Stack>
</Group>
</SummaryCard>
<SummaryCard
title="Total Activity Today"
value="3,842"
icon={TbActivity}
color="teal"
trend={{ value: '14.2%', positive: true }}
/>
<SummaryCard
<SummaryCard
title="Total Villages Active"
value="138"
icon={TbBuildingCommunity}
color="indigo"
progress={{ value: 98, label: 'Integration Health' }}
/>
<SummaryCard
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
>
<Group justify="space-between" mt="md">
<Text size="xs" c="dimmed">Nonactive Villages</Text>
<Badge size="sm" color="red" variant="light">24</Badge>
</Group>
</SummaryCard>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
@@ -121,5 +143,6 @@ function AppOverviewPage() {
{/* 🐞 4. LATEST ERROR REPORTS */}
<ErrorDataTable />
</Stack>
</>
)
}

View File

@@ -1,278 +0,0 @@
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

@@ -53,7 +53,7 @@ function ProductsPage() {
{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' }}>
<Box h={160} style={{ background: 'var(--mantine-color-default-hover)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
<TbArchive size={34} />
</ThemeIcon>
@@ -90,7 +90,7 @@ function ProductsPage() {
/>
</Box>
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Tooltip label="Edit Product">
<ActionIcon variant="light" size="sm" color="blue">
<TbPencil size={14} />

View File

@@ -2,6 +2,7 @@ import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import {
Box,
Container,
Divider,
Group,
Stack,
Text,
@@ -27,7 +28,7 @@ function AppDetailLayout() {
return (
<DashboardLayout>
<Container size="xl" py="lg">
<Stack gap="xl">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title>
@@ -35,7 +36,9 @@ function AppDetailLayout() {
</Stack>
</Group>
<Box mt="md">
<Divider />
<Box>
<Outlet />
</Box>
</Stack>

View File

@@ -0,0 +1,468 @@
import { AreaChart } from '@mantine/charts'
import {
Badge,
Box,
Button,
Card,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
TbArrowLeft,
TbBuildingCommunity,
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbCircleCheck,
TbEdit,
TbHome2,
TbLayoutKanban,
TbMapPin,
TbPower,
TbUser,
TbUsers,
TbUsersGroup,
TbWifi
} from 'react-icons/tb'
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage,
})
// ── Mock Data ────────────────────────────────────────────────────────────────
const mockVillages: Record<string, any> = {
'sukatani': {
id: 'sukatani',
name: 'Sukatani',
kecamatan: 'Tapos',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
kodePos: '16455',
perbekel: 'H. Suryana, S.Sos',
createdAt: '2024-03-12',
createdBy: 'Admin Pusat',
updatedAt: '2024-04-01',
status: 'fully integrated',
lastSync: '2 menit lalu',
stats: { users: 1240, groups: 34, divisions: 8, activities: 4520 },
},
'sukamaju': {
id: 'sukamaju',
name: 'Sukamaju',
kecamatan: 'Cilodong',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
kodePos: '16413',
perbekel: 'Drs. H. Mujiono',
createdAt: '2024-04-01',
createdBy: 'Amel',
updatedAt: '2024-04-10',
status: 'sync active',
lastSync: '15 menit lalu',
stats: { users: 980, groups: 28, divisions: 6, activities: 3180 },
},
'cikini': {
id: 'cikini',
name: 'Cikini',
kecamatan: 'Menteng',
kabupaten: 'Jakarta Pusat',
provinsi: 'DKI Jakarta',
kodePos: '10330',
perbekel: 'Ir. Budi Santoso',
createdAt: '2024-05-20',
createdBy: 'Jane Smith',
updatedAt: '2024-05-25',
status: 'sync pending',
lastSync: 'Belum pernah sync',
stats: { users: 420, groups: 12, divisions: 3, activities: 640 },
},
'bojong-gede': {
id: 'bojong-gede',
name: 'Bojong Gede',
kecamatan: 'Bojong Gede',
kabupaten: 'Kabupaten Bogor',
provinsi: 'Jawa Barat',
kodePos: '16920',
perbekel: 'H. Rahmat Hidayat, M.Si',
createdAt: '2024-02-15',
createdBy: 'Rahmat',
updatedAt: '2024-04-02',
status: 'fully integrated',
lastSync: '1 jam lalu',
stats: { users: 1890, groups: 51, divisions: 12, activities: 7340 },
},
'ciputat': {
id: 'ciputat',
name: 'Ciputat',
kecamatan: 'Ciputat',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
kodePos: '15411',
perbekel: 'Drs. Ahmad Fauzi',
createdAt: '2024-06-10',
createdBy: 'Admin Pusat',
updatedAt: '2024-06-15',
status: 'sync active',
lastSync: '30 menit lalu',
stats: { users: 1120, groups: 30, divisions: 7, activities: 3860 },
},
'serpong': {
id: 'serpong',
name: 'Serpong',
kecamatan: 'Serpong',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
kodePos: '15310',
perbekel: 'H. Bambang Wijaya',
createdAt: '2024-07-05',
createdBy: 'Amel',
updatedAt: '2024-07-10',
status: 'sync pending',
lastSync: 'Belum tersinkronisasi',
stats: { users: 280, groups: 8, divisions: 2, activities: 310 },
},
}
// ── Chart Data Generators ─────────────────────────────────────────────────────
function generateDailyData() {
const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']
const today = new Date()
return Array.from({ length: 14 }, (_, i) => {
const d = new Date(today)
d.setDate(today.getDate() - (13 - i))
const dayName = days[d.getDay() === 0 ? 6 : d.getDay() - 1]
const dateStr = `${dayName} ${d.getDate()}/${d.getMonth() + 1}`
return {
label: dateStr,
aktivitas: Math.floor(Math.random() * 300 + 60),
}
})
}
function generateMonthlyData() {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
return months.map((m) => ({
label: m,
aktivitas: Math.floor(Math.random() * 2000 + 800),
}))
}
function generateYearlyData() {
return ['2021', '2022', '2023', '2024'].map((y) => ({
label: y,
aktivitas: Math.floor(Math.random() * 15000 + 5000),
}))
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi Penuh' },
'sync active': { color: 'blue', label: 'Sync Aktif' },
'sync pending': { color: 'orange', label: 'Menunggu Sync' },
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
// ── Activity Chart ────────────────────────────────────────────────────────────
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart() {
const [period, setPeriod] = useState<ChartPeriod>('monthly')
const dataMap: Record<ChartPeriod, any[]> = {
daily: generateDailyData(),
monthly: generateMonthlyData(),
yearly: generateYearlyData(),
}
const labels: Record<ChartPeriod, string> = {
daily: 'Harian (14 hari terakhir)',
monthly: 'Bulanan (tahun ini)',
yearly: 'Tahunan',
}
const data = dataMap[period]
return (
<Paper withBorder radius="xl" p="lg">
<Group justify="space-between" mb="lg" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="blue">
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Log Aktivitas Desa</Text>
<Text size="xs" c="dimmed">{labels[period]}</Text>
</Stack>
</Group>
<SegmentedControl
value={period}
onChange={(v) => setPeriod(v as ChartPeriod)}
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Harian' },
{ value: 'monthly', label: 'Bulanan' },
{ value: 'yearly', label: 'Tahunan' },
]}
/>
</Group>
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]}
curveType="monotone"
withTooltip
withDots
tickLine="none"
gridAxis="x"
tooltipAnimationDuration={150}
fillOpacity={1}
areaProps={{
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}}
dotProps={{
r: 4,
strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}}
>
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
</Paper>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate()
const village = mockVillages[villageId]
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (!village) {
return (
<Stack align="center" py="xl" gap="md">
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
<Title order={4}>Desa tidak ditemukan</Title>
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Kembali ke Daftar
</Button>
</Stack>
)
}
const cfg = statusConfig[village.status as keyof typeof statusConfig]
const { stats } = village
return (
<Stack gap="xl">
{/* ── Back Button ── */}
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<TbArrowLeft size={16} />}
radius="md"
onClick={goBack}
>
Daftar Desa
</Button>
{/* Action Buttons */}
<Group gap="sm">
<Button
variant="filled"
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'}
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)}
radius="md"
>
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
</Button>
<Button
variant="light"
color="blue"
leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)}
radius="md"
>
Edit Desa
</Button>
</Group>
</Group>
{/* ── Header Banner ── */}
<Paper
radius="xl"
p="xl"
style={{
background: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 60%, #7c3aed 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Decorative blobs */}
<Box style={{ position: 'absolute', top: -50, right: -50, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
<Box style={{ position: 'absolute', bottom: -70, right: 100, width: 160, height: 160, borderRadius: '50%', background: 'rgba(255,255,255,0.04)' }} />
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="lg">
<ThemeIcon
size={68}
radius="xl"
style={{ background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.2)' }}
>
<TbHome2 size={32} color="white" />
</ThemeIcon>
<Stack gap={6}>
<Title order={2} style={{ color: 'white', lineHeight: 1.1 }}>{village.name}</Title>
<Group gap={6}>
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
</Text>
</Group>
<Group gap={6}>
<TbUser size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Perbekel: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
</Text>
</Group>
<Group gap="xs" mt={2}>
<Badge
variant="outline"
radius="sm"
size="sm"
style={{ color: 'white', borderColor: 'rgba(255,255,255,0.45)' }}
leftSection={<TbCircleCheck size={11} />}
>
{cfg.label}
</Badge>
<Badge
variant="outline"
radius="sm"
size="sm"
style={{ color: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,255,255,0.25)' }}
>
Kode Pos: {village.kodePos}
</Badge>
</Group>
</Stack>
</Group>
{/* Last Sync block */}
<Stack gap={4} align="flex-end">
<Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text>
<Group gap={6}>
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.lastSync}</Text>
</Group>
</Stack>
</Group>
</Paper>
{/* ── Stats Cards ── */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' },
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' },
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' },
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' },
].map((s) => (
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs">
<s.icon size={18} />
</ThemeIcon>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{s.label}
</Text>
<Text size="xl" fw={800} mt={2}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
{/* ── Chart + Info Panels ── */}
<Box
style={{
display: 'grid',
gridTemplateColumns: '3fr 1fr',
gap: '1rem',
alignItems: 'start',
}}
>
{/* Left (3/4): Activity Chart */}
<ActivityChart />
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} />
</ThemeIcon>
<Text fw={700} size="sm">Informasi Sistem</Text>
</Group>
<Stack gap={0}>
{[
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) },
{ label: 'Dibuat Oleh', value: village.createdBy },
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) },
].map((item, idx, arr) => (
<Group
key={item.label}
justify="space-between"
py="xs"
wrap="wrap"
style={{
borderBottom: idx < arr.length - 1 ? '1px solid var(--mantine-color-default-border)' : 'none',
}}
>
<Text size="xs" c="dimmed">{item.label}</Text>
<Text size="xs" fw={600} ta="right">{item.value}</Text>
</Group>
))}
</Stack>
</Paper>
</Box>
</Stack>
)
}

View File

@@ -0,0 +1,366 @@
import { useState } from 'react'
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon,
TextInput,
Tooltip,
SimpleGrid,
Avatar,
Box,
SegmentedControl,
Card,
Divider,
ThemeIcon,
} from '@mantine/core'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbBuildingCommunity,
TbLayoutGrid,
TbList,
TbMapPin,
TbCalendar,
TbUser,
TbHome2,
TbArrowRight,
TbChevronRight,
} from 'react-icons/tb'
export const Route = createFileRoute('/apps/$appId/villages/')({
component: AppVillagesIndexPage,
})
const mockVillages = [
{
id: 'sukatani',
name: 'Sukatani',
kecamatan: 'Tapos',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
perbekel: 'H. Suryana, S.Sos',
createdAt: '2024-03-12',
createdBy: 'Admin Pusat',
status: 'fully integrated',
population: 4500,
},
{
id: 'sukamaju',
name: 'Sukamaju',
kecamatan: 'Cilodong',
kabupaten: 'Kota Depok',
provinsi: 'Jawa Barat',
perbekel: 'Drs. H. Mujiono',
createdAt: '2024-04-01',
createdBy: 'Amel',
status: 'sync active',
population: 3800,
},
{
id: 'cikini',
name: 'Cikini',
kecamatan: 'Menteng',
kabupaten: 'Jakarta Pusat',
provinsi: 'DKI Jakarta',
perbekel: 'Ir. Budi Santoso',
createdAt: '2024-05-20',
createdBy: 'Jane Smith',
status: 'sync pending',
population: 2100,
},
{
id: 'bojong-gede',
name: 'Bojong Gede',
kecamatan: 'Bojong Gede',
kabupaten: 'Kabupaten Bogor',
provinsi: 'Jawa Barat',
perbekel: 'H. Rahmat Hidayat, M.Si',
createdAt: '2024-02-15',
createdBy: 'Rahmat',
status: 'fully integrated',
population: 6700,
},
{
id: 'ciputat',
name: 'Ciputat',
kecamatan: 'Ciputat',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
perbekel: 'Drs. Ahmad Fauzi',
createdAt: '2024-06-10',
createdBy: 'Admin Pusat',
status: 'sync active',
population: 5200,
},
{
id: 'serpong',
name: 'Serpong',
kecamatan: 'Serpong',
kabupaten: 'Tangerang Selatan',
provinsi: 'Banten',
perbekel: 'H. Bambang Wijaya',
createdAt: '2024-07-05',
createdBy: 'Amel',
status: 'sync pending',
population: 8900,
},
]
const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi' },
'sync active': { color: 'blue', label: 'Sync Aktif' },
'sync pending': { color: 'orange', label: 'Sync Pending' },
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
return (
<Card
withBorder
radius="xl"
padding="lg"
className="village-card"
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group justify="space-between" mb="md">
<ThemeIcon
size={46}
radius="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
>
<TbHome2 size={22} />
</ThemeIcon>
<Badge color={cfg.color} variant="light" radius="sm" size="sm">
{cfg.label}
</Badge>
</Group>
<Text fw={800} size="lg" mb={2}>
{village.name}
</Text>
<Group gap={4} mb="md">
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten}
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{village.provinsi}
</Text>
<Divider my="sm" />
<Stack gap={6}>
<Group gap="xs">
<TbUser size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Perbekel:</Text>
<Text size="xs" fw={600}>{village.perbekel}</Text>
</Group>
<Group gap="xs">
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Dibuat:</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
</Group>
<Group gap="xs">
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
<Text size="xs" c="dimmed">Oleh:</Text>
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Stack>
<Button
variant="light"
color="brand-blue"
size="compact-sm"
fullWidth
mt="md"
radius="md"
rightSection={<TbArrowRight size={14} />}
styles={{ root: { fontSize: 12 } }}
>
Lihat Detail
</Button>
</Card>
)
}
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
return (
<Paper
withBorder
radius="lg"
p="md"
className="village-list-row"
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
>
<TbHome2 size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap="sm">
<Text fw={700} size="sm">{village.name}</Text>
<Badge color={cfg.color} variant="light" radius="sm" size="xs">
{cfg.label}
</Badge>
</Group>
<Group gap={6}>
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
</Text>
</Group>
</Stack>
</Group>
<Group gap="xl" visibleFrom="md">
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Perbekel</Text>
<Text size="xs" fw={600}>{village.perbekel}</Text>
</Stack>
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text>
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
</Stack>
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
<Group gap={4}>
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Stack>
</Group>
<ActionIcon variant="light" color="brand-blue" radius="md">
<TbChevronRight size={16} />
</ActionIcon>
</Group>
</Paper>
)
}
function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [search, setSearch] = useState('')
const isDesaPlus = appId === 'desa-plus'
const filtered = mockVillages.filter((v) =>
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
)
const handleVillageClick = (villageId: string) => {
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
}
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">
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={3}>Daftar Desa</Title>
<Text size="sm" c="dimmed">
{filtered.length} desa terdaftar dalam platform <strong>Desa+</strong>
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
>
Tambah Desa
</Button>
</Group>
<Group justify="space-between">
<TextInput
placeholder="Cari desa, kecamatan, kabupaten..."
leftSection={<TbSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
style={{ flex: 1, maxWidth: 400 }}
/>
<SegmentedControl
value={viewMode}
onChange={(v) => setViewMode(v as 'grid' | 'list')}
data={[
{ value: 'grid', label: <Tooltip label="Grid View"><Box><TbLayoutGrid size={16} /></Box></Tooltip> },
{ value: 'list', label: <Tooltip label="List View"><Box><TbList size={16} /></Box></Tooltip> },
]}
radius="md"
/>
</Group>
{filtered.length === 0 ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">Tidak ada desa yang cocok dengan pencarian.</Text>
</Paper>
) : viewMode === 'grid' ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{filtered.map((village) => (
<VillageGridCard
key={village.id}
village={village}
onClick={() => handleVillageClick(village.id)}
/>
))}
</SimpleGrid>
) : (
<Stack gap="sm">
{filtered.map((village) => (
<VillageListRow
key={village.id}
village={village}
onClick={() => handleVillageClick(village.id)}
/>
))}
</Stack>
)}
</Stack>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/apps/$appId/villages')({
component: VillagesLayout,
})
function VillagesLayout() {
return <Outlet />
}

View File

@@ -0,0 +1,214 @@
import {
Badge,
Container,
Group,
Stack,
Text,
Paper,
TextInput,
Select,
Avatar,
Box,
Divider,
} from '@mantine/core'
import { useState, useMemo } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
export const Route = createFileRoute('/logs')({
component: GlobalLogsPage,
})
const timelineData = [
{
date: 'TODAY',
logs: [
{ id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document <Badge variant="light" color="gray" radius="sm">Surat Domisili</Badge> for <Badge variant="light" color="blue" radius="sm">Sukatani</Badge></> },
{ 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></> },
{ 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.' } },
{ 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</> },
]
},
{
date: 'YESTERDAY',
logs: [
{ 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</> },
{ 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</> },
{ 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.</> },
{ 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.</> },
]
},
{
date: '12 APRIL, 2026',
logs: [
{ 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></> },
{ 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></> },
]
}
]
function GlobalLogsPage() {
const [search, setSearch] = useState('')
const [appFilter, setAppFilter] = useState<string | null>(null)
const [operatorFilter, setOperatorFilter] = useState<string | null>(null)
const filteredTimeline = useMemo(() => {
return timelineData
.map(group => {
const filteredLogs = group.logs.filter(log => {
if (appFilter && log.app !== appFilter) return false;
if (operatorFilter && log.operator !== operatorFilter) return false;
if (search) {
const lSearch = search.toLowerCase();
if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
return false;
}
}
return true;
});
return { ...group, logs: filteredLogs };
})
.filter(group => group.logs.length > 0);
}, [search, appFilter, operatorFilter]);
return (
<DashboardLayout>
<Container size="xl" py="lg">
{/* Header Controls */}
<Group mb="xl" gap="md">
<TextInput
placeholder="Search operator or app..."
leftSection={<TbSearch size={16} />}
radius="md"
w={220}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Select
placeholder="All Applications"
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']}
radius="md"
w={160}
clearable
value={appFilter}
onChange={setAppFilter}
/>
<Select
placeholder="All Operators"
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']}
radius="md"
w={160}
clearable
value={operatorFilter}
onChange={setOperatorFilter}
/>
</Group>
{/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}>
{filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="lg"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
{log.icon ? (
<Avatar size={24} radius="xl" color={log.color} variant="light">
<log.icon size={14} />
</Avatar>
) : (
<Avatar size={24} radius="xl" color={log.color}>
{log.operator.charAt(0)}
</Avatar>
)}
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
{log.content}
</Text>
{log.message && (
<Paper
withBorder
p="md"
radius="md"
mt="sm"
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
>
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
<Text size="sm" c="dimmed">
{log.message.text}
</Text>
</Paper>
)}
</Box>
</Group>
)
})}
</Stack>
{groupIndex < timelineData.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
)}
</Box>
))}
</Paper>
</Container>
</DashboardLayout>
)
}

View File

@@ -35,8 +35,8 @@ import {
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/settings')({
component: SettingsPage,
export const Route = createFileRoute('/users')({
component: UsersPage,
})
const mockUsers = [
@@ -67,14 +67,14 @@ const roles = [
},
]
function SettingsPage() {
function UsersPage() {
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>
<Title order={2} className="gradient-text">Users</Title>
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
</Stack>
</Group>

View File

@@ -1,5 +1,5 @@
@import '@mantine/core/styles.css';
@import '@mantine/charts/styles.css';
:root {
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
@@ -27,8 +27,8 @@ html, body {
height: 100%;
width: 100%;
font-family: var(--font-inter);
background-color: var(--bg-dark); /* Default to Dark Mode as per App.tsx */
color: #F8FAFC;
/* background-color handled by Mantine */
color: var(--mantine-color-text);
}
body {
@@ -53,9 +53,9 @@ body {
/* Premium Dashboard Utilities */
.glass {
background: rgba(30, 41, 59, 0.7);
background: var(--mantine-color-default);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(128, 128, 128, 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);
}
@@ -111,3 +111,40 @@ body {
.data-table tbody tr:hover {
background: rgba(124, 58, 237, 0.03);
}
/* Village Cards */
.village-card {
transition: var(--transition-smooth);
background: var(--mantine-color-body);
border-color: rgba(128, 128, 128, 0.12) !important;
}
.village-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 32px -12px rgba(37, 99, 235, 0.25);
border-color: rgba(37, 99, 235, 0.3) !important;
}
.village-list-row {
transition: var(--transition-smooth);
background: var(--mantine-color-body);
border-color: rgba(128, 128, 128, 0.12) !important;
}
.village-list-row:hover {
transform: translateX(4px);
box-shadow: 0 4px 16px -6px rgba(37, 99, 235, 0.2);
border-color: rgba(37, 99, 235, 0.3) !important;
}
/* Village Detail Page Grid */
.village-detail-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
align-items: start;
}
@media (min-width: 768px) {
.village-detail-grid {
grid-template-columns: 3fr 1fr;
}
}