upd: menghubungkan dengan api desa+

Deskripsi:
- list desa
- detail desa

No Issues
This commit is contained in:
2026-04-09 12:16:25 +08:00
parent e889a97e2a
commit 4fad913890
6 changed files with 242 additions and 346 deletions

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}