From a47d61e9afa929e87bb7fa1ad237c381994cc9ed Mon Sep 17 00:00:00 2001 From: amal Date: Sat, 4 Apr 2026 12:10:36 +0800 Subject: [PATCH] upd: tampilan detail desa --- .../apps.$appId.villages.$villageId.tsx | 468 ++++++++++++++++++ .../routes/apps.$appId.villages.index.tsx | 366 ++++++++++++++ src/frontend/routes/apps.$appId.villages.tsx | 277 +---------- src/index.css | 37 ++ 4 files changed, 875 insertions(+), 273 deletions(-) create mode 100644 src/frontend/routes/apps.$appId.villages.$villageId.tsx create mode 100644 src/frontend/routes/apps.$appId.villages.index.tsx diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx new file mode 100644 index 0000000..754a85b --- /dev/null +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -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 = { + '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('monthly') + + const dataMap: Record = { + daily: generateDailyData(), + monthly: generateMonthlyData(), + yearly: generateYearlyData(), + } + + const labels: Record = { + daily: 'Harian (14 hari terakhir)', + monthly: 'Bulanan (tahun ini)', + yearly: 'Tahunan', + } + + const data = dataMap[period] + + return ( + + + + + + + + Log Aktivitas Desa + {labels[period]} + + + + setPeriod(v as ChartPeriod)} + size="xs" + radius="md" + data={[ + { value: 'daily', label: 'Harian' }, + { value: 'monthly', label: 'Bulanan' }, + { value: 'yearly', label: 'Tahunan' }, + ]} + /> + + + + + + + + + + + + + ) +} + +// ── 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 ( + + + Desa tidak ditemukan + ID desa "{villageId}" tidak terdaftar dalam sistem. + + + ) + } + + const cfg = statusConfig[village.status as keyof typeof statusConfig] + const { stats } = village + + return ( + + + {/* ── Back Button ── */} + + + + {/* Action Buttons */} + + + + + + + {/* ── Header Banner ── */} + + {/* Decorative blobs */} + + + + + + + + + + + {village.name} + + + + + Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} + + + + + + + Perbekel: {village.perbekel} + + + + + } + > + {cfg.label} + + + Kode Pos: {village.kodePos} + + + + + + {/* Last Sync block */} + + Last Sync + + + {village.lastSync} + + + + + + {/* ── Stats Cards ── */} + + {[ + { 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) => ( + + + + + + {s.label} + + {s.value} + + ))} + + + {/* ── Chart + Info Panels ── */} + + {/* Left (3/4): Activity Chart */} + + + {/* Right (1/4): Informasi Sistem */} + + + + + + Informasi Sistem + + + {[ + { label: 'Tanggal Dibuat', value: formatDate(village.createdAt) }, + { label: 'Dibuat Oleh', value: village.createdBy }, + { label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) }, + ].map((item, idx, arr) => ( + + {item.label} + {item.value} + + ))} + + + + + + ) +} diff --git a/src/frontend/routes/apps.$appId.villages.index.tsx b/src/frontend/routes/apps.$appId.villages.index.tsx new file mode 100644 index 0000000..0bd6a08 --- /dev/null +++ b/src/frontend/routes/apps.$appId.villages.index.tsx @@ -0,0 +1,366 @@ +import { useState } from 'react' +import { + Badge, + Container, + Group, + Stack, + Text, + Title, + Paper, + Button, + ActionIcon, + TextInput, + Tooltip, + SimpleGrid, + Avatar, + Box, + SegmentedControl, + Card, + Divider, + ThemeIcon, +} from '@mantine/core' +import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { + TbPlus, + TbSearch, + TbBuildingCommunity, + TbLayoutGrid, + TbList, + TbMapPin, + TbCalendar, + TbUser, + TbHome2, + TbArrowRight, + TbChevronRight, +} from 'react-icons/tb' + +export const Route = createFileRoute('/apps/$appId/villages/')({ + component: AppVillagesIndexPage, +}) + +const mockVillages = [ + { + id: 'sukatani', + name: 'Sukatani', + kecamatan: 'Tapos', + kabupaten: 'Kota Depok', + provinsi: 'Jawa Barat', + perbekel: 'H. Suryana, S.Sos', + createdAt: '2024-03-12', + createdBy: 'Admin Pusat', + status: 'fully integrated', + population: 4500, + }, + { + id: 'sukamaju', + name: 'Sukamaju', + kecamatan: 'Cilodong', + kabupaten: 'Kota Depok', + provinsi: 'Jawa Barat', + perbekel: 'Drs. H. Mujiono', + createdAt: '2024-04-01', + createdBy: 'Amel', + status: 'sync active', + population: 3800, + }, + { + id: 'cikini', + name: 'Cikini', + kecamatan: 'Menteng', + kabupaten: 'Jakarta Pusat', + provinsi: 'DKI Jakarta', + perbekel: 'Ir. Budi Santoso', + createdAt: '2024-05-20', + createdBy: 'Jane Smith', + status: 'sync pending', + population: 2100, + }, + { + id: 'bojong-gede', + name: 'Bojong Gede', + kecamatan: 'Bojong Gede', + kabupaten: 'Kabupaten Bogor', + provinsi: 'Jawa Barat', + perbekel: 'H. Rahmat Hidayat, M.Si', + createdAt: '2024-02-15', + createdBy: 'Rahmat', + status: 'fully integrated', + population: 6700, + }, + { + id: 'ciputat', + name: 'Ciputat', + kecamatan: 'Ciputat', + kabupaten: 'Tangerang Selatan', + provinsi: 'Banten', + perbekel: 'Drs. Ahmad Fauzi', + createdAt: '2024-06-10', + createdBy: 'Admin Pusat', + status: 'sync active', + population: 5200, + }, + { + id: 'serpong', + name: 'Serpong', + kecamatan: 'Serpong', + kabupaten: 'Tangerang Selatan', + provinsi: 'Banten', + perbekel: 'H. Bambang Wijaya', + createdAt: '2024-07-05', + createdBy: 'Amel', + status: 'sync pending', + population: 8900, + }, +] + +const statusConfig = { + 'fully integrated': { color: 'teal', label: 'Terintegrasi' }, + 'sync active': { color: 'blue', label: 'Sync Aktif' }, + 'sync pending': { color: 'orange', label: 'Sync Pending' }, +} + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString('id-ID', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + +function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) { + const cfg = statusConfig[village.status as keyof typeof statusConfig] + return ( + + + + + + + {cfg.label} + + + + + {village.name} + + + + + Kec. {village.kecamatan} · {village.kabupaten} + + + + + {village.provinsi} + + + + + + + + Perbekel: + {village.perbekel} + + + + Dibuat: + {formatDate(village.createdAt)} + + + + Oleh: + {village.createdBy} + + + + + + ) +} + +function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) { + const cfg = statusConfig[village.status as keyof typeof statusConfig] + return ( + + + + + + + + + {village.name} + + {cfg.label} + + + + + + Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} + + + + + + + + Perbekel + {village.perbekel} + + + Dibuat + {formatDate(village.createdAt)} + + + Oleh + + + {village.createdBy} + + + + + + + + + + ) +} + +function AppVillagesIndexPage() { + const { appId } = useParams({ from: '/apps/$appId' }) + const navigate = useNavigate() + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [search, setSearch] = useState('') + + const isDesaPlus = appId === 'desa-plus' + + const filtered = mockVillages.filter((v) => + [v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel] + .join(' ') + .toLowerCase() + .includes(search.toLowerCase()) + ) + + const handleVillageClick = (villageId: string) => { + navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } }) + } + + if (!isDesaPlus) { + return ( + + + + General Management + This feature is currently customized for Desa+. Other apps coming soon. + + + ) + } + + return ( + + + + Daftar Desa + + {filtered.length} desa terdaftar dalam platform Desa+ + + + + + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + radius="md" + style={{ flex: 1, maxWidth: 400 }} + /> + setViewMode(v as 'grid' | 'list')} + data={[ + { value: 'grid', label: }, + { value: 'list', label: }, + ]} + radius="md" + /> + + + {filtered.length === 0 ? ( + + + Tidak ada desa yang cocok dengan pencarian. + + ) : viewMode === 'grid' ? ( + + {filtered.map((village) => ( + handleVillageClick(village.id)} + /> + ))} + + ) : ( + + {filtered.map((village) => ( + handleVillageClick(village.id)} + /> + ))} + + )} + + ) +} diff --git a/src/frontend/routes/apps.$appId.villages.tsx b/src/frontend/routes/apps.$appId.villages.tsx index ef065c6..62f1e40 100644 --- a/src/frontend/routes/apps.$appId.villages.tsx +++ b/src/frontend/routes/apps.$appId.villages.tsx @@ -1,278 +1,9 @@ -import { useState } from 'react' -import { - Badge, - Container, - Group, - Stack, - Text, - Title, - Paper, - Table, - Button, - ActionIcon, - TextInput, - Select, - Tooltip, - SimpleGrid, - Modal, - Avatar, - Box, - NumberInput, -} from '@mantine/core' -import { useDisclosure } from '@mantine/hooks' -import { createFileRoute, useParams } from '@tanstack/react-router' -import { - TbPlus, - TbSearch, - TbPencil, - TbTrash, - TbUserPlus, - TbCircleCheck, - TbRefresh, - TbUser, - TbBuildingCommunity, -} from 'react-icons/tb' -import { StatsCard } from '@/frontend/components/StatsCard' +import { createFileRoute, Outlet } from '@tanstack/react-router' export const Route = createFileRoute('/apps/$appId/villages')({ - component: AppVillagesPage, + component: VillagesLayout, }) -const mockDevelopers = [ - { value: 'john-doe', label: 'John Doe', avatar: null }, - { value: 'amel', label: 'Amel', avatar: null }, - { value: 'jane-smith', label: 'Jane Smith', avatar: null }, - { value: 'rahmat', label: 'Rahmat Hidayat', avatar: null }, -] - -function AppVillagesPage() { - const { appId } = useParams({ from: '/apps/$appId' }) - const [initModalOpened, { open: openInit, close: closeInit }] = useDisclosure(false) - const [assignModalOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false) - const [selectedVillage, setSelectedVillage] = useState(null) - - const isDesaPlus = appId === 'desa-plus' - - const mockVillages = [ - { id: 1, name: 'Sukatani', kecamatan: 'Tapos', population: 4500, status: 'fully integrated', developer: 'John Doe', lastUpdate: '2 mins ago' }, - { id: 2, name: 'Sukamaju', kecamatan: 'Cilodong', population: 3800, status: 'sync active', developer: 'Amel', lastUpdate: '15 mins ago' }, - { id: 3, name: 'Cikini', kecamatan: 'Menteng', population: 2100, status: 'sync pending', developer: 'Jane Smith', lastUpdate: '-' }, - { id: 4, name: 'Bojong Gede', kecamatan: 'Bojong Gede', population: 6700, status: 'fully integrated', developer: 'Rahmat', lastUpdate: '1 hour ago' }, - ] - - if (!isDesaPlus) { - return ( - - - - General Management - This feature is currently customized for Desa+. Other apps coming soon. - - - ) - } - - return ( - - {/* Metrics Row */} - - - - - - - - - - Village Deployment Center - Monitor and configure **Desa+** village instances across all districts. - - - - - - - } - style={{ flex: 1 }} - radius="md" - /> - - - - - - Village Profile - District - Integration Status - Lead Developer - Last Sync - Actions - - - - {mockVillages.map((village) => ( - - - - {village.name} - {village.population.toLocaleString()} Residents - - - - {village.kecamatan} - - - } - radius="sm" - style={{ textTransform: 'uppercase', fontVariant: 'small-caps' }} - > - {village.status} - - - - - - {village.developer} - { setSelectedVillage(village); openAssign(); }} - > - - - - - - - {village.lastUpdate} - - - - - {village.status === 'sync pending' && ( - - )} - - - - - - - - - - - - - - ))} - -
-
- - {/* MODALS */} - Desa+ Instance Initialization} - radius="xl" - centered - padding="xl" - > - - - - - - - } - radius="md" - searchable - /> - - - - - - -
- ) +function VillagesLayout() { + return } diff --git a/src/index.css b/src/index.css index 798a67d..02ab2e7 100644 --- a/src/index.css +++ b/src/index.css @@ -111,3 +111,40 @@ body { .data-table tbody tr:hover { background: rgba(124, 58, 237, 0.03); } + +/* Village Cards */ +.village-card { + transition: var(--transition-smooth); + background: var(--mantine-color-body); + border-color: rgba(128, 128, 128, 0.12) !important; +} +.village-card:hover { + transform: translateY(-6px); + box-shadow: 0 16px 32px -12px rgba(37, 99, 235, 0.25); + border-color: rgba(37, 99, 235, 0.3) !important; +} + +.village-list-row { + transition: var(--transition-smooth); + background: var(--mantine-color-body); + border-color: rgba(128, 128, 128, 0.12) !important; +} +.village-list-row:hover { + transform: translateX(4px); + box-shadow: 0 4px 16px -6px rgba(37, 99, 235, 0.2); + border-color: rgba(37, 99, 235, 0.3) !important; +} + +/* Village Detail Page Grid */ +.village-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + align-items: start; +} + +@media (min-width: 768px) { + .village-detail-grid { + grid-template-columns: 3fr 1fr; + } +}