7 Commits

Author SHA1 Message Date
e1b9241c35 Merge pull request 'amalia/09-apr-26' (#5) from amalia/09-apr-26 into main
Reviewed-on: #5
2026-04-09 17:34:49 +08:00
cc49a1fcd3 upd: connected api
Deskripsi:
- tambah desa

No Issues
2026-04-09 17:30:55 +08:00
c63b8cd385 upd: connected api monitoring
Deskripsi:
- update version

No Issues
2026-04-09 16:58:02 +08:00
ba74539542 upd: connected api monitoring
Deskripsi:
- overview page

No Issues
2026-04-09 15:21:10 +08:00
3a91bb5b17 upd: connected api monitoring
Deskripsi:
- list log semua desa

No Issues
2026-04-09 14:35:56 +08:00
91ad56348f upd: connected api monitoring
Deskripsi:
- list user
- tampilan page list user

No Issues
2026-04-09 14:27:49 +08:00
4fad913890 upd: menghubungkan dengan api desa+
Deskripsi:
- list desa
- detail desa

No Issues
2026-04-09 12:16:25 +08:00
14 changed files with 1149 additions and 567 deletions

View File

@@ -6,6 +6,7 @@
"name": "bun-react-template",
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
@@ -21,6 +22,7 @@
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1",
"swr": "^2.4.1",
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
@@ -99,6 +101,8 @@
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -427,6 +431,8 @@
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -705,6 +711,8 @@
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -22,10 +22,12 @@
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
@@ -36,7 +38,8 @@
"react": "^19",
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"swr": "^2.4.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",

View File

@@ -1,5 +1,7 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { Notifications } from '@mantine/notifications'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
@@ -61,6 +63,7 @@ export function App() {
<>
<ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>

View File

@@ -11,25 +11,12 @@ import {
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 },
]
interface ChartProps {
data?: any[]
isLoading?: boolean
}
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() {
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
@@ -46,14 +33,14 @@ export function VillageActivityLineChart() {
</Box>
</Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
Growing
{isLoading ? '...' : 'Live'}
</Badge>
</Group>
<Box h={300} mt="lg">
<LineChart
h={300}
data={activityData}
data={data}
dataKey="date"
series={[{ name: 'logs', color: '#2563EB' }]}
curveType="monotone"
@@ -76,7 +63,7 @@ export function VillageActivityLineChart() {
)
}
export function VillageComparisonBarChart() {
export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
@@ -89,7 +76,7 @@ export function VillageComparisonBarChart() {
</ThemeIcon>
<Box>
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text>
<Text size="xs" c="dimmed">Most active village deployments</Text>
</Box>
</Group>
</Group>
@@ -97,7 +84,7 @@ export function VillageComparisonBarChart() {
<Box h={300} mt="lg">
<BarChart
h={300}
data={villageComparisonData}
data={data}
dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]}
withTooltip
@@ -112,7 +99,6 @@ export function VillageComparisonBarChart() {
}
}}
>
{/* 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} />

View File

@@ -0,0 +1,21 @@
export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS
export const API_URLS = {
getVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
infoVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/info-villages?id=${id}`,
gridVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) =>
`${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
getUsers: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
getLogsAllVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
}

View File

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

View File

@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react'
import { notifications } from '@mantine/notifications'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard'
@@ -13,10 +15,12 @@ import {
TextInput,
Switch,
Badge,
Textarea
Textarea,
Skeleton
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import useSWR from 'swr'
import {
TbActivity,
TbAlertTriangle,
@@ -24,125 +28,200 @@ import {
TbRefresh,
TbVersions
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/')({
component: AppOverviewPage,
})
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
// Form State
const [latestVersion, setLatestVersion] = useState('')
const [minVersion, setMinVersion] = useState('')
const [messageUpdate, setMessageUpdate] = useState('')
const [maintenance, setMaintenance] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Data Fetching
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
const grid = gridRes?.data
const dailyData = dailyRes?.data || []
const comparisonData = comparisonRes?.data || []
// Initialize form when data loads or modal opens
useEffect(() => {
if (grid?.version && versionModalOpened) {
setLatestVersion(grid.version.mobile_latest_version || '')
setMinVersion(grid.version.mobile_minimum_version || '')
setMessageUpdate(grid.version.mobile_message_update || '')
setMaintenance(grid.version.mobile_maintenance === 'true')
}
}, [grid, versionModalOpened])
const handleRefresh = () => {
mutateGrid()
mutateDaily()
mutateComparison()
}
const handleSaveVersion = async () => {
setIsSaving(true)
try {
const response = await fetch(API_URLS.postVersionUpdate(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mobile_latest_version: latestVersion,
mobile_minimum_version: minVersion,
mobile_maintenance: maintenance,
mobile_message_update: messageUpdate,
}),
})
if (response.ok) {
notifications.show({
title: 'Update Successful',
message: 'Application version information has been updated.',
color: 'teal',
})
mutateGrid()
closeVersionModal()
} else {
notifications.show({
title: 'Update Failed',
message: 'Failed to update version information. Please check your data.',
color: 'red',
})
}
} catch (error) {
notifications.show({
title: 'Network Error',
message: 'Could not connect to the server. Please try again later.',
color: 'red',
})
} finally {
setIsSaving(false)
}
}
return (
<>
<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" />
<TextInput
label="Active Version"
placeholder="e.g. 2.0.5"
value={latestVersion}
onChange={(e) => setLatestVersion(e.currentTarget.value)}
/>
<TextInput
label="Minimum Version"
placeholder="e.g. 2.0.0"
value={minVersion}
onChange={(e) => setMinVersion(e.currentTarget.value)}
/>
<Textarea
label="Update Message"
placeholder="Enter release notes or update message..."
value={messageUpdate}
onChange={(e) => setMessageUpdate(e.currentTarget.value)}
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>
<Switch
label="Maintenance Mode"
description="Enable to put the app in maintenance mode for users."
checked={maintenance}
onChange={(e) => setMaintenance(e.currentTarget.checked)}
/>
<Button fullWidth onClick={handleSaveVersion} loading={isSaving}>Save Changes</Button>
</Stack>
</Modal>
<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 justify="space-between">
<Stack gap={0}>
<Title order={3}>Overview</Title>
<Text size="sm" c="dimmed">Detailed metrics for {isDesaPlus ? 'Desa+' : appId}</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
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED' }}
radius="md"
leftSection={<TbFilter size={18} />}
>
Add Filter
</Button> */}
{/* <Group gap="md">
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md" onClick={handleRefresh}>
<TbRefresh size={20} />
</ActionIcon>
</Group> */}
</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"
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
title="Total Villages Active"
value="138"
icon={TbBuildingCommunity}
color="indigo"
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}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SummaryCard
title="Active Version"
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
icon={TbVersions}
color="brand-blue"
onClick={openVersionModal}
>
<Group justify="space-between" mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">Min. Version</Text>
<Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '-'}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed">Maintenance</Text>
<Badge size="sm" color={grid?.version?.mobile_maintenance === 'true' ? 'red' : 'gray'} variant="light">
{grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
</Badge>
</Stack>
</Group>
</SummaryCard>
{/* 📈 📊 2 & 3. CHARTS GRID */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart />
<VillageComparisonBarChart />
</SimpleGrid>
<SummaryCard
title="Total Activity Today"
value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() || '0')}
icon={TbActivity}
color="teal"
trend={grid?.activity?.increase ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } : undefined}
/>
{/* 🐞 4. LATEST ERROR REPORTS */}
<ErrorDataTable />
</Stack>
<SummaryCard
title="Total Villages Active"
value={gridLoading ? '...' : (grid?.village?.active || '0')}
icon={TbBuildingCommunity}
color="indigo"
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">{grid?.village?.inactive || 0}</Badge>
</Group>
</SummaryCard>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
</SimpleGrid>
<ErrorDataTable />
</Stack>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import useSWR from 'swr'
import {
Badge,
Container,
Group,
Stack,
Text,
@@ -8,116 +9,244 @@ import {
Paper,
Table,
TextInput,
Select,
ActionIcon,
Tooltip,
Avatar,
Code,
Button
Button,
Box,
Pagination,
ThemeIcon,
ScrollArea,
Container,
} from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
import {
TbSearch,
TbDownload,
TbX,
TbHistory,
TbCalendar,
TbUser,
TbHome2
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
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' },
]
interface LogEntry {
id: string
createdAt: string
action: string
desc: string
username: string
village: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus'
const isMobile = useMediaQuery('(max-width: 768px)')
const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
const logs: LogEntry[] = response?.data?.log || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const getActionColor = (action: string) => {
const a = action.toUpperCase()
if (a === 'LOGIN') return 'blue'
if (a === 'LOGOUT') return 'gray'
if (a === 'CREATE') return 'teal'
if (a === 'UPDATE') return 'orange'
if (a === 'DELETE') return 'red'
return 'brand-blue'
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbHistory size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">Activity Logs</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="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>
<Stack gap="xl" py="md">
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #7C3AED' }}>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="violet" size="lg" radius="md">
<TbHistory size={22} />
</ThemeIcon>
<Title order={3}>Activity Logs</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
</Text>
</Stack>
<Button
variant="light"
color="gray"
leftSection={<TbDownload size={18} />}
radius="md"
size="md"
>
Export
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput
placeholder="Search activity, village, or operator..."
leftSection={<TbSearch size={16} />}
placeholder="Search action or village..."
leftSection={<TbSearch size={18} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ maxWidth: 500 }}
ml={40}
/>
<Select
placeholder="All Service Types"
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
leftSection={<TbFilter size={16} />}
</Stack>
</Paper>
{isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Fetching activity logs...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load logs from API.</Text>
</Paper>
) : logs.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbHistory size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No activity found for this search.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '18%' }}>Timestamp</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Action</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '50%' }}>Description</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log) => (
<Table.Tr key={log.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon variant="transparent" color="gray" size="sm">
<TbCalendar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text size="xs" fw={700} style={{ color: 'var(--mantine-color-white)' }}>
{log.createdAt.split(' ').slice(1).join(' ')}
</Text>
<Text size="xs" c="dimmed">
{log.createdAt.split(' ')[0]}
</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap">
<Avatar size="xs" radius="xl" color="brand-blue" variant="light">
{log.username.charAt(0)}
</Avatar>
<Text size="xs" fw={700} truncate="end">{log.username}</Text>
</Group>
<Group gap={8} wrap="nowrap">
<TbHome2 size={12} color="gray" />
<Text size="xs" c="dimmed" truncate="end">{log.village}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="dot"
color={getActionColor(log.action)}
radius="sm"
size="xs"
styles={{ root: { fontWeight: 800 } }}
>
{log.action}
</Badge>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="rgba(37, 99, 235, 0.05)" fw={600} style={{ fontSize: '11px', display: 'block', whiteSpace: 'normal' }}>
{log.desc}
</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md"
clearable
withEdges={false}
siblings={1}
boundaries={1}
/>
</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,299 @@
import { useState } from 'react'
import useSWR from 'swr'
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon,
TextInput,
Table,
Avatar,
Box,
Pagination,
ThemeIcon,
ScrollArea,
Tooltip,
} from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbUsers,
TbX,
TbMail,
TbPhone,
TbId,
TbBriefcase,
TbHome2,
TbCircleCheck,
TbCircleX,
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({
component: UsersIndexPage,
})
interface APIUser {
id: string
name: string
nik: string
phone: string
email: string
isWithoutOTP: boolean
isActive: boolean
role: string
village: string
group: string
position?: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function UsersIndexPage() {
const { appId } = useParams({ from: '/apps/$appId/users/' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
const users: APIUser[] = response?.data?.user || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const getRoleColor = (role: string) => {
const r = role.toLowerCase()
if (r.includes('super')) return 'red'
if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
const isMobile = useMediaQuery('(max-width: 768px)')
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbUsers size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">User Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl" py="md">
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #2563EB' }}>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbUsers size={22} />
</ThemeIcon>
<Title order={3}>User Management</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
size="md"
>
Add User
</Button>
</Group>
<TextInput
placeholder="Search name, NIK, or email..."
leftSection={<TbSearch size={18} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ maxWidth: 500 }}
ml={40}
/>
</Stack>
</Paper>
{isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Loading user data...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load data from API.</Text>
</Paper>
) : users.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbUsers size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No users match your criteria.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td>
<Group gap="md" wrap="nowrap">
<Avatar
size="lg"
radius="md"
variant="light"
color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
>
{user.name.charAt(0)}
</Avatar>
<Stack gap={2} style={{ overflow: 'hidden' }}>
<Text fw={700} size="sm" truncate="end" style={{ color: 'var(--mantine-color-white)' }}>{user.name}</Text>
<Group gap={4} wrap="nowrap">
<TbId size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed" style={{ letterSpacing: '0.5px' }} truncate="end">{user.nik}</Text>
</Group>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbMail size={14} />
</ThemeIcon>
<Text size="xs" fw={500} truncate="end">{user.email}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbPhone size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.phone}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="light" color="blue" radius="sm">
<TbHome2 size={12} />
</ThemeIcon>
<Text size="xs" fw={700} truncate="end">{user.village}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbBriefcase size={12} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.group} · {user.position || 'Staff'}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="filled"
color={getRoleColor(user.role)}
radius="md"
size="sm"
fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap="xs" wrap="nowrap">
{user.isActive ? (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', boxShadow: '0 0 8px #10b981' }} />
) : (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
)}
<Text size="xs" fw={800} c={user.isActive ? 'teal.4' : 'red.5'}>
{user.isActive ? 'ACTIVE' : 'INACTIVE'}
</Text>
</Group>
{user.isWithoutOTP && (
<Badge variant="light" color="orange" size="xs" radius="sm">
NO OTP
</Badge>
)}
</Stack>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md"
withEdges={false}
siblings={1}
boundaries={1}
/>
</Group>
)}
</Stack>
)
}

View File

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

View File

@@ -1,6 +1,5 @@
import { AreaChart } from '@mantine/charts'
import {
Badge,
Box,
Button,
Card,
@@ -11,7 +10,7 @@ import {
Stack,
Text,
ThemeIcon,
Title,
Title
} from '@mantine/core'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
@@ -21,7 +20,6 @@ import {
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbCircleCheck,
TbEdit,
TbHome2,
TbLayoutKanban,
@@ -32,6 +30,10 @@ import {
TbUsersGroup,
TbWifi
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage,
@@ -39,165 +41,33 @@ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
// ── 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 },
},
}
// Mock data removed as it is replaced by API calls
// ── 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),
}))
}
// Remove chart data generators as they are replaced by API calls
// ── 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')
function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily')
const dataMap: Record<ChartPeriod, any[]> = {
daily: generateDailyData(),
monthly: generateMonthlyData(),
yearly: generateYearlyData(),
}
const { data: response, isLoading } = useSWR(
API_URLS.graphLogVillages(villageId, period),
fetcher
)
const labels: Record<ChartPeriod, string> = {
daily: 'Harian (14 hari terakhir)',
monthly: 'Bulanan (tahun ini)',
yearly: 'Tahunan',
daily: 'Daily (last 14 days)',
monthly: 'Monthly (this year)',
yearly: 'Yearly',
}
const data = dataMap[period]
const data = response?.data || []
return (
<Paper withBorder radius="xl" p="lg">
@@ -207,7 +77,7 @@ function ActivityChart() {
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Log Aktivitas Desa</Text>
<Text fw={700} size="sm">Village Activity Log</Text>
<Text size="xs" c="dimmed">{labels[period]}</Text>
</Stack>
</Group>
@@ -218,46 +88,52 @@ function ActivityChart() {
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Harian' },
{ value: 'monthly', label: 'Bulanan' },
{ value: 'yearly', label: 'Tahunan' },
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
</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>
{isLoading ? (
<Stack h={280} align="center" justify="center">
<Text size="sm" c="dimmed">Loading chart data...</Text>
</Stack>
) : (
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
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>
)
}
@@ -267,26 +143,36 @@ function ActivityChart() {
function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate()
const village = mockVillages[villageId]
const { data: infoRes, isLoading: infoLoading } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
const village = infoRes?.data
const stats = gridRes?.data
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) {
return (
<Stack align="center" py="xl" gap="md">
<Text c="dimmed">Loading village data...</Text>
</Stack>
)
}
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>
<Title order={4}>Village not found</Title>
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Kembali ke Daftar
Back to List
</Button>
</Stack>
)
}
const cfg = statusConfig[village.status as keyof typeof statusConfig]
const { stats } = village
return (
<Stack gap="xl">
@@ -300,19 +186,19 @@ function VillageDetailPage() {
radius="md"
onClick={goBack}
>
Daftar Desa
Village List
</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} />}
color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <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'}
{village.isActive ? 'Deactivate' : 'Active'}
</Button>
<Button
variant="light"
@@ -321,7 +207,7 @@ function VillageDetailPage() {
onClick={() => alert(`Edit settings for ${village.name}`)}
radius="md"
>
Edit Desa
Edit
</Button>
</Group>
</Group>
@@ -356,18 +242,18 @@ function VillageDetailPage() {
<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}
Location data not available
</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>
Village Head: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
</Text>
</Group>
<Group gap="xs" mt={2}>
{/* <Group gap="xs" mt={2}>
<Badge
variant="outline"
radius="sm"
@@ -377,24 +263,16 @@ function VillageDetailPage() {
>
{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>
</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>
{/* <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>
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
</Group>
</Stack>
</Group>
@@ -403,19 +281,25 @@ function VillageDetailPage() {
{/* ── 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' },
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, 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>
<Group justify="space-between" align="flex-start" mb="xs">
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
<s.icon size={18} />
</ThemeIcon>
<Stack gap={0} align="flex-end">
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
</Stack>
</Group>
<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>
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
</Card>
))}
</SimpleGrid>
@@ -430,7 +314,7 @@ function VillageDetailPage() {
}}
>
{/* Left (3/4): Activity Chart */}
<ActivityChart />
<ActivityChart villageId={villageId} />
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
@@ -438,13 +322,13 @@ function VillageDetailPage() {
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} />
</ThemeIcon>
<Text fw={700} size="sm">Informasi Sistem</Text>
<Text fw={700} size="sm">System Information</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) },
{ label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' },
].map((item, idx, arr) => (
<Group
key={item.label}

View File

@@ -1,134 +1,82 @@
import { useState } from 'react'
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
Group,
Modal,
Pagination,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon,
Textarea,
TextInput,
Tooltip,
SimpleGrid,
Avatar,
Box,
SegmentedControl,
Card,
Divider,
ThemeIcon,
Title,
Tooltip
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { useDisclosure } from '@mantine/hooks'
import {
TbPlus,
TbSearch,
TbArrowRight,
TbBuildingCommunity,
TbCalendar,
TbChevronRight,
TbHome2,
TbLayoutGrid,
TbList,
TbMapPin,
TbCalendar,
TbPlus,
TbSearch,
TbUser,
TbHome2,
TbArrowRight,
TbChevronRight,
TbX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
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,
},
]
interface APIVillage {
id: string
name: string
isActive: boolean
createdAt: string
perbekel: string | null
}
const statusConfig = {
'fully integrated': { color: 'teal', label: 'Terintegrasi' },
'sync active': { color: 'blue', label: 'Sync Aktif' },
'sync pending': { color: 'orange', label: 'Sync Pending' },
'active': { color: 'teal', label: 'Active' },
'inactive': { color: 'orange', label: 'Inactive' },
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
} catch (e) {
return dateStr
}
}
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Card
withBorder
@@ -158,12 +106,12 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<Group gap={4} mb="md">
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten}
No location details available
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{village.provinsi}
-
</Text>
<Divider my="sm" />
@@ -171,19 +119,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
<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>
<Text size="xs" c="dimmed">Village Head:</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>
<Text size="xs" c="dimmed">Created:</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Group>
<Group gap="xs">
{/* <Group gap="xs">
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
<Text size="xs" c="dimmed">Oleh:</Text>
<Text size="xs" c="dimmed">By:</Text>
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Group> */}
</Stack>
<Button
@@ -196,14 +144,15 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
rightSection={<TbArrowRight size={14} />}
styles={{ root: { fontSize: 12 } }}
>
Lihat Detail
View Details
</Button>
</Card>
)
}
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
const cfg = statusConfig[village.status as keyof typeof statusConfig]
function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Paper
withBorder
@@ -233,7 +182,7 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<Group gap={6}>
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
No location details available
</Text>
</Group>
</Stack>
@@ -241,20 +190,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
<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>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Village Head</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>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Stack>
<Stack gap={0} align="center">
{/* <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>
</Stack> */}
</Group>
<ActionIcon variant="light" color="brand-blue" radius="md">
@@ -269,21 +218,105 @@ function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// Form State
const [isSubmitting, setIsSubmitting] = useState(false)
const [form, setForm] = useState({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
const filtered = mockVillages.filter((v) =>
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase())
)
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const villages: APIVillage[] = response?.data || []
const handleVillageClick = (villageId: string) => {
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
}
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const handleCreateVillage = async () => {
const requiredFields = ['name', 'desc', 'username', 'phone', 'nik', 'email', 'gender'] as const
const isFormValid = requiredFields.every(field => !!form[field])
if (!isFormValid) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required to register a new village.',
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
if (res.ok) {
notifications.show({
title: 'Success',
message: 'Village has been successfully registered.',
color: 'teal'
})
mutate() // Refresh list
closeCreateModal()
setForm({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
} else {
notifications.show({
title: 'Error',
message: 'Failed to create village. Please try again.',
color: 'red'
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to reach API server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
@@ -298,11 +331,105 @@ function AppVillagesIndexPage() {
return (
<Stack gap="xl">
<Modal
opened={createModalOpened}
onClose={closeCreateModal}
title={<Text fw={700} size="lg">Register New Village</Text>}
radius="xl"
size="lg"
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Village Data
</Text>
<Stack gap="sm">
<TextInput
label="Village Name"
placeholder="e.g. Darmasaba"
required
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Short description about the village..."
minRows={3}
required
value={form.desc}
onChange={(e) => setForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
</Stack>
</Box>
<Divider label="Village Head Information" labelPosition="center" my="sm" />
<Box>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Head Name (Username)"
placeholder="Full name of village head"
required
value={form.username}
onChange={(e) => setForm(prev => ({ ...prev, username: e.currentTarget.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(prev => ({ ...prev, nik: e.currentTarget.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email"
placeholder="Email address"
required
value={form.email}
onChange={(e) => setForm(prev => ({ ...prev, email: e.currentTarget.value }))}
/>
<TextInput
label="Phone"
placeholder="Active WhatsApp number"
required
value={form.phone}
onChange={(e) => setForm(prev => ({ ...prev, phone: e.currentTarget.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={['Male', 'Female']}
mt="sm"
required
value={form.gender}
onChange={(val) => setForm(prev => ({ ...prev, gender: val || '' }))}
/>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateVillage}
>
Create Village
</Button>
</Stack>
</Modal>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={3}>Daftar Desa</Title>
<Title order={3}>Village List</Title>
<Text size="sm" c="dimmed">
{filtered.length} desa terdaftar dalam platform <strong>Desa+</strong>
{isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`}
</Text>
</Stack>
<Button
@@ -310,17 +437,25 @@ function AppVillagesIndexPage() {
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openCreateModal}
>
Tambah Desa
Create New Village
</Button>
</Group>
<Group justify="space-between">
<TextInput
placeholder="Cari desa, kecamatan, kabupaten..."
placeholder="Search village, district, city..."
leftSection={<TbSearch size={16} />}
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={14} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ flex: 1, maxWidth: 400 }}
/>
@@ -335,14 +470,27 @@ function AppVillagesIndexPage() {
/>
</Group>
{filtered.length === 0 ? (
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{[1, 2, 3].map((i) => (
<Card key={i} withBorder radius="xl" padding="lg" style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text c="dimmed">Loading...</Text>
</Card>
))}
</SimpleGrid>
) : error ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="red" opacity={0.4} />
<Text c="red" mt="md">Failed to load data from API.</Text>
</Paper>
) : villages.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>
<Text c="dimmed" mt="md">No villages match your search.</Text>
</Paper>
) : viewMode === 'grid' ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{filtered.map((village) => (
{villages.map((village) => (
<VillageGridCard
key={village.id}
village={village}
@@ -352,7 +500,7 @@ function AppVillagesIndexPage() {
</SimpleGrid>
) : (
<Stack gap="sm">
{filtered.map((village) => (
{villages.map((village) => (
<VillageListRow
key={village.id}
village={village}
@@ -361,6 +509,18 @@ function AppVillagesIndexPage() {
))}
</Stack>
)}
{!isLoading && !error && response?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.totalPage}
radius="md"
withEdges={false}
/>
</Group>
)}
</Stack>
)
}

View File

@@ -60,12 +60,12 @@ function LoginPage() {
<Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br />
atau: <strong>user@example.com</strong> / <strong>user123</strong>
or: <strong>user@example.com</strong> / <strong>user123</strong>
</Text>
{(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
{login.isError ? login.error.message : 'Google login failed, please try again.'}
</Alert>
)}
@@ -96,7 +96,7 @@ function LoginPage() {
Sign in
</Button>
<Divider label="atau" labelPosition="center" />
<Divider label="or" labelPosition="center" />
<Button
component="a"
@@ -105,7 +105,7 @@ function LoginPage() {
variant="default"
leftSection={<FcGoogle size={18} />}
>
Login dengan Google
Login with Google
</Button>
</Stack>
</form>