upd: tampilan detail desa
This commit is contained in:
468
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal file
468
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user