From 4fad913890389d067df768222ac3375b10690f1f Mon Sep 17 00:00:00 2001 From: amal Date: Thu, 9 Apr 2026 12:16:25 +0800 Subject: [PATCH] upd: menghubungkan dengan api desa+ Deskripsi: - list desa - detail desa No Issues --- bun.lock | 8 + package.json | 4 +- src/frontend/config/api.ts | 12 + .../apps.$appId.villages.$villageId.tsx | 330 ++++++------------ .../routes/apps.$appId.villages.index.tsx | 226 ++++++------ src/frontend/routes/login.tsx | 8 +- 6 files changed, 242 insertions(+), 346 deletions(-) create mode 100644 src/frontend/config/api.ts diff --git a/bun.lock b/bun.lock index 1a60939..6a37e41 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index eddf26e..7d357e2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "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", @@ -36,7 +37,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", diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts new file mode 100644 index 0000000..1b719ee --- /dev/null +++ b/src/frontend/config/api.ts @@ -0,0 +1,12 @@ +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}`, +} diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index 754a85b..4bb7f12 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -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 = { - '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('monthly') +function ActivityChart({ villageId }: { villageId: string }) { + const [period, setPeriod] = useState('daily') - const dataMap: Record = { - daily: generateDailyData(), - monthly: generateMonthlyData(), - yearly: generateYearlyData(), - } + const { data: response, isLoading } = useSWR( + API_URLS.graphLogVillages(villageId, period), + fetcher + ) const labels: Record = { - 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 ( @@ -207,7 +77,7 @@ function ActivityChart() { - Log Aktivitas Desa + Village Activity Log {labels[period]} @@ -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' }, ]} /> - - - - - - - - - + {isLoading ? ( + + Loading chart data... + + ) : ( + + + + + + + + + + )} ) } @@ -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 ( + + Loading village data... + + ) + } + if (!village) { return ( - Desa tidak ditemukan - ID desa "{villageId}" tidak terdaftar dalam sistem. + Village not found + Village ID "{villageId}" is not registered in the system. ) } - const cfg = statusConfig[village.status as keyof typeof statusConfig] - const { stats } = village - return ( @@ -300,19 +186,19 @@ function VillageDetailPage() { radius="md" onClick={goBack} > - Daftar Desa + Village List {/* Action Buttons */} @@ -356,18 +242,18 @@ function VillageDetailPage() { - Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} + Location data not available - Perbekel: {village.perbekel} + Village Head: {village.perbekel} - + {/* {cfg.label} - - Kode Pos: {village.kodePos} - - + */} {/* Last Sync block */} - Last Sync + {/* Last Sync */} - {village.lastSync} + {village.isActive ? 'ACTIVE' : 'NON-ACTIVE'} @@ -403,19 +281,25 @@ function VillageDetailPage() { {/* ── 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' }, + { 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) => ( - - - + + + + + + NON-ACTIVE + {s.nonActive?.toLocaleString('id-ID') || 0} + + {s.label} - {s.value} + {s.active?.toLocaleString('id-ID') || 0} ))} @@ -430,7 +314,7 @@ function VillageDetailPage() { }} > {/* Left (3/4): Activity Chart */} - + {/* Right (1/4): Informasi Sistem */} @@ -438,13 +322,13 @@ function VillageDetailPage() { - Informasi Sistem + System Information {[ - { 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) => ( 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 ( - Kec. {village.kecamatan} · {village.kabupaten} + No location details available - {village.provinsi} + - @@ -171,19 +115,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0] - Perbekel: - {village.perbekel} + Village Head: + {village.perbekel || '-'} - Dibuat: - {formatDate(village.createdAt)} + Created: + {village.createdAt} - + {/* - Oleh: + By: {village.createdBy} - + */} ) } -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 ( - Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi} + No location details available @@ -241,20 +186,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; - Perbekel - {village.perbekel} + Village Head + {village.perbekel || '-'} - Dibuat - {formatDate(village.createdAt)} + Created + {village.createdAt} - + {/* Oleh {village.createdBy} - + */} @@ -269,21 +214,34 @@ function AppVillagesIndexPage() { const { appId } = useParams({ from: '/apps/$appId' }) const navigate = useNavigate() const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [page, setPage] = useState(1) const [search, setSearch] = useState('') + const [searchQuery, setSearchQuery] = useState('') 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 } = 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) + } + if (!isDesaPlus) { return ( @@ -300,9 +258,9 @@ function AppVillagesIndexPage() { - Daftar Desa + Village List - {filtered.length} desa terdaftar dalam platform Desa+ + {isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`} } + rightSection={ + search ? ( + + + + ) : null + } value={search} - onChange={(e) => setSearch(e.currentTarget.value)} + onChange={(e) => handleSearchChange(e.currentTarget.value)} radius="md" style={{ flex: 1, maxWidth: 400 }} /> @@ -335,14 +300,27 @@ function AppVillagesIndexPage() { /> - {filtered.length === 0 ? ( + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + Loading... + + ))} + + ) : error ? ( + + + Failed to load data from API. + + ) : villages.length === 0 ? ( - Tidak ada desa yang cocok dengan pencarian. + No villages match your search. ) : viewMode === 'grid' ? ( - {filtered.map((village) => ( + {villages.map((village) => ( ) : ( - {filtered.map((village) => ( + {villages.map((village) => ( )} + + {!isLoading && !error && response?.totalPage > 0 && ( + + + + )} ) } diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index cdb5836..81079e9 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -60,12 +60,12 @@ function LoginPage() { Demo: superadmin@example.com / superadmin123
- atau: user@example.com / user123 + or: user@example.com / user123
{(login.isError || searchError) && ( } 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.'} )} @@ -96,7 +96,7 @@ function LoginPage() { Sign in - +