upd: menghubungkan dengan api desa+
Deskripsi: - list desa - detail desa No Issues
This commit is contained in:
8
bun.lock
8
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "bun-react-template",
|
"name": "bun-react-template",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@mantine/charts": "^9.0.0",
|
"@mantine/charts": "^9.0.0",
|
||||||
"@mantine/core": "^8.3.18",
|
"@mantine/core": "^8.3.18",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"swr": "^2.4.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@mantine/charts": "^9.0.0",
|
"@mantine/charts": "^9.0.0",
|
||||||
"@mantine/core": "^8.3.18",
|
"@mantine/core": "^8.3.18",
|
||||||
@@ -36,7 +37,8 @@
|
|||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"swr": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.10",
|
||||||
|
|||||||
12
src/frontend/config/api.ts
Normal file
12
src/frontend/config/api.ts
Normal file
@@ -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}`,
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AreaChart } from '@mantine/charts'
|
import { AreaChart } from '@mantine/charts'
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -21,7 +20,6 @@ import {
|
|||||||
TbCalendar,
|
TbCalendar,
|
||||||
TbCalendarEvent,
|
TbCalendarEvent,
|
||||||
TbChartBar,
|
TbChartBar,
|
||||||
TbCircleCheck,
|
|
||||||
TbEdit,
|
TbEdit,
|
||||||
TbHome2,
|
TbHome2,
|
||||||
TbLayoutKanban,
|
TbLayoutKanban,
|
||||||
@@ -32,6 +30,10 @@ import {
|
|||||||
TbUsersGroup,
|
TbUsersGroup,
|
||||||
TbWifi
|
TbWifi
|
||||||
} from 'react-icons/tb'
|
} 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')({
|
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
||||||
component: VillageDetailPage,
|
component: VillageDetailPage,
|
||||||
@@ -39,165 +41,33 @@ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
|||||||
|
|
||||||
// ── Mock Data ────────────────────────────────────────────────────────────────
|
// ── Mock Data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockVillages: Record<string, any> = {
|
// Mock data removed as it is replaced by API calls
|
||||||
'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 ─────────────────────────────────────────────────────
|
// Remove chart data generators as they are replaced by API calls
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────────────
|
// ── Activity Chart ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
||||||
|
|
||||||
function ActivityChart() {
|
function ActivityChart({ villageId }: { villageId: string }) {
|
||||||
const [period, setPeriod] = useState<ChartPeriod>('monthly')
|
const [period, setPeriod] = useState<ChartPeriod>('daily')
|
||||||
|
|
||||||
const dataMap: Record<ChartPeriod, any[]> = {
|
const { data: response, isLoading } = useSWR(
|
||||||
daily: generateDailyData(),
|
API_URLS.graphLogVillages(villageId, period),
|
||||||
monthly: generateMonthlyData(),
|
fetcher
|
||||||
yearly: generateYearlyData(),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const labels: Record<ChartPeriod, string> = {
|
const labels: Record<ChartPeriod, string> = {
|
||||||
daily: 'Harian (14 hari terakhir)',
|
daily: 'Daily (last 14 days)',
|
||||||
monthly: 'Bulanan (tahun ini)',
|
monthly: 'Monthly (this year)',
|
||||||
yearly: 'Tahunan',
|
yearly: 'Yearly',
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = dataMap[period]
|
const data = response?.data || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="xl" p="lg">
|
<Paper withBorder radius="xl" p="lg">
|
||||||
@@ -207,7 +77,7 @@ function ActivityChart() {
|
|||||||
<TbChartBar size={14} />
|
<TbChartBar size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={0}>
|
<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>
|
<Text size="xs" c="dimmed">{labels[period]}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -218,18 +88,23 @@ function ActivityChart() {
|
|||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
data={[
|
data={[
|
||||||
{ value: 'daily', label: 'Harian' },
|
{ value: 'daily', label: 'Daily' },
|
||||||
{ value: 'monthly', label: 'Bulanan' },
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
{ value: 'yearly', label: 'Tahunan' },
|
{ value: 'yearly', label: 'Yearly' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack h={280} align="center" justify="center">
|
||||||
|
<Text size="sm" c="dimmed">Loading chart data...</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
h={280}
|
h={280}
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]}
|
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
withTooltip
|
withTooltip
|
||||||
withDots
|
withDots
|
||||||
@@ -258,6 +133,7 @@ function ActivityChart() {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,26 +143,36 @@ function ActivityChart() {
|
|||||||
function VillageDetailPage() {
|
function VillageDetailPage() {
|
||||||
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
||||||
const navigate = useNavigate()
|
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 } })
|
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) {
|
if (!village) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" py="xl" gap="md">
|
<Stack align="center" py="xl" gap="md">
|
||||||
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
||||||
<Title order={4}>Desa tidak ditemukan</Title>
|
<Title order={4}>Village not found</Title>
|
||||||
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text>
|
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
|
||||||
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
||||||
Kembali ke Daftar
|
Back to List
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
|
||||||
const { stats } = village
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
|
||||||
@@ -300,19 +186,19 @@ function VillageDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
>
|
>
|
||||||
Daftar Desa
|
Village List
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'}
|
color={village.isActive ? 'red' : 'green'}
|
||||||
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />}
|
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
|
||||||
onClick={() => alert(`Toggle status for ${village.name}`)}
|
onClick={() => alert(`Toggle status for ${village.name}`)}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
|
{village.isActive ? 'Deactivate' : 'Active'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -321,7 +207,7 @@ function VillageDetailPage() {
|
|||||||
onClick={() => alert(`Edit settings for ${village.name}`)}
|
onClick={() => alert(`Edit settings for ${village.name}`)}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
Edit Desa
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -356,18 +242,18 @@ function VillageDetailPage() {
|
|||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
|
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
|
||||||
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
Location data not available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbUser size={14} color="rgba(255,255,255,0.8)" />
|
<TbUser size={14} color="rgba(255,255,255,0.8)" />
|
||||||
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
<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>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs" mt={2}>
|
{/* <Group gap="xs" mt={2}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
@@ -377,24 +263,16 @@ function VillageDetailPage() {
|
|||||||
>
|
>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
</Group> */}
|
||||||
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>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Last Sync block */}
|
{/* Last Sync block */}
|
||||||
<Stack gap={4} align="flex-end">
|
<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}>
|
<Group gap={6}>
|
||||||
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
|
<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>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -403,19 +281,25 @@ function VillageDetailPage() {
|
|||||||
{/* ── Stats Cards ── */}
|
{/* ── Stats Cards ── */}
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||||
{[
|
{[
|
||||||
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' },
|
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
|
||||||
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' },
|
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
|
||||||
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' },
|
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
|
||||||
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' },
|
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
||||||
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs">
|
<Group justify="space-between" align="flex-start" mb="xs">
|
||||||
|
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
|
||||||
<s.icon size={18} />
|
<s.icon size={18} />
|
||||||
</ThemeIcon>
|
</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' }}>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</Text>
|
</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>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -430,7 +314,7 @@ function VillageDetailPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left (3/4): Activity Chart */}
|
{/* Left (3/4): Activity Chart */}
|
||||||
<ActivityChart />
|
<ActivityChart villageId={villageId} />
|
||||||
|
|
||||||
{/* Right (1/4): Informasi Sistem */}
|
{/* Right (1/4): Informasi Sistem */}
|
||||||
<Paper withBorder radius="xl" p="lg">
|
<Paper withBorder radius="xl" p="lg">
|
||||||
@@ -438,13 +322,13 @@ function VillageDetailPage() {
|
|||||||
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
||||||
<TbCalendar size={14} />
|
<TbCalendar size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fw={700} size="sm">Informasi Sistem</Text>
|
<Text fw={700} size="sm">System Information</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) },
|
{ label: 'Date Created', value: village.createdAt },
|
||||||
{ label: 'Dibuat Oleh', value: village.createdBy },
|
{ label: 'Created By', value: '-' },
|
||||||
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) },
|
{ label: 'Last Updated', value: '-' },
|
||||||
].map((item, idx, arr) => (
|
].map((item, idx, arr) => (
|
||||||
<Group
|
<Group
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Container,
|
Container,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Pagination,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
@@ -32,103 +34,45 @@ import {
|
|||||||
TbHome2,
|
TbHome2,
|
||||||
TbArrowRight,
|
TbArrowRight,
|
||||||
TbChevronRight,
|
TbChevronRight,
|
||||||
|
TbX,
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/villages/')({
|
export const Route = createFileRoute('/apps/$appId/villages/')({
|
||||||
component: AppVillagesIndexPage,
|
component: AppVillagesIndexPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockVillages = [
|
interface APIVillage {
|
||||||
{
|
id: string
|
||||||
id: 'sukatani',
|
name: string
|
||||||
name: 'Sukatani',
|
isActive: boolean
|
||||||
kecamatan: 'Tapos',
|
createdAt: string
|
||||||
kabupaten: 'Kota Depok',
|
perbekel: string | null
|
||||||
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' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'active': { color: 'teal', label: 'Active' },
|
||||||
|
'inactive': { color: 'orange', label: 'Inactive' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleDateString('id-ID', {
|
if (!dateStr) return '-'
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
const status = village.isActive ? 'active' : 'inactive'
|
||||||
|
const cfg = statusConfig[status as keyof typeof statusConfig]
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
@@ -158,12 +102,12 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
<Group gap={4} mb="md">
|
<Group gap={4} mb="md">
|
||||||
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
|
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Kec. {village.kecamatan} · {village.kabupaten}
|
No location details available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{village.provinsi}
|
-
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
@@ -171,19 +115,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<TbUser size={13} color="var(--mantine-color-dimmed)" />
|
<TbUser size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">Perbekel:</Text>
|
<Text size="xs" c="dimmed">Village Head:</Text>
|
||||||
<Text size="xs" fw={600}>{village.perbekel}</Text>
|
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
|
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">Dibuat:</Text>
|
<Text size="xs" c="dimmed">Created:</Text>
|
||||||
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
<Text size="xs" fw={600}>{village.createdAt}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
{/* <Group gap="xs">
|
||||||
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
|
<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>
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
</Group>
|
</Group> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -196,14 +140,15 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
rightSection={<TbArrowRight size={14} />}
|
rightSection={<TbArrowRight size={14} />}
|
||||||
styles={{ root: { fontSize: 12 } }}
|
styles={{ root: { fontSize: 12 } }}
|
||||||
>
|
>
|
||||||
Lihat Detail
|
View Details
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
const status = village.isActive ? 'active' : 'inactive'
|
||||||
|
const cfg = statusConfig[status as keyof typeof statusConfig]
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
@@ -233,7 +178,7 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
|
|||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
|
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
No location details available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -241,20 +186,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
|
|||||||
|
|
||||||
<Group gap="xl" visibleFrom="md">
|
<Group gap="xl" visibleFrom="md">
|
||||||
<Stack gap={0} align="center">
|
<Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>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>
|
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0} align="center">
|
<Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
|
||||||
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
<Text size="xs" fw={600}>{village.createdAt}</Text>
|
||||||
</Stack>
|
</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>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
|
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
|
||||||
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionIcon variant="light" color="brand-blue" radius="md">
|
<ActionIcon variant="light" color="brand-blue" radius="md">
|
||||||
@@ -269,21 +214,34 @@ function AppVillagesIndexPage() {
|
|||||||
const { appId } = useParams({ from: '/apps/$appId' })
|
const { appId } = useParams({ from: '/apps/$appId' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
|
||||||
|
|
||||||
const filtered = mockVillages.filter((v) =>
|
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
|
||||||
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
|
const villages: APIVillage[] = response?.data || []
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleVillageClick = (villageId: string) => {
|
const handleVillageClick = (villageId: string) => {
|
||||||
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
|
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) {
|
if (!isDesaPlus) {
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Container size="xl" py="xl">
|
||||||
@@ -300,9 +258,9 @@ function AppVillagesIndexPage() {
|
|||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Title order={3}>Daftar Desa</Title>
|
<Title order={3}>Village List</Title>
|
||||||
<Text size="sm" c="dimmed">
|
<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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
<Button
|
||||||
@@ -311,16 +269,23 @@ function AppVillagesIndexPage() {
|
|||||||
leftSection={<TbPlus size={18} />}
|
leftSection={<TbPlus size={18} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
Tambah Desa
|
Add Village
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Cari desa, kecamatan, kabupaten..."
|
placeholder="Search village, district, city..."
|
||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
|
rightSection={
|
||||||
|
search ? (
|
||||||
|
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
|
||||||
|
<TbX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => handleSearchChange(e.currentTarget.value)}
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{ flex: 1, maxWidth: 400 }}
|
style={{ flex: 1, maxWidth: 400 }}
|
||||||
/>
|
/>
|
||||||
@@ -335,14 +300,27 @@ function AppVillagesIndexPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</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' }}>
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
|
<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>
|
</Paper>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
{filtered.map((village) => (
|
{villages.map((village) => (
|
||||||
<VillageGridCard
|
<VillageGridCard
|
||||||
key={village.id}
|
key={village.id}
|
||||||
village={village}
|
village={village}
|
||||||
@@ -352,7 +330,7 @@ function AppVillagesIndexPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{filtered.map((village) => (
|
{villages.map((village) => (
|
||||||
<VillageListRow
|
<VillageListRow
|
||||||
key={village.id}
|
key={village.id}
|
||||||
village={village}
|
village={village}
|
||||||
@@ -361,6 +339,18 @@ function AppVillagesIndexPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</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>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ function LoginPage() {
|
|||||||
<Text c="dimmed" size="sm" ta="center">
|
<Text c="dimmed" size="sm" ta="center">
|
||||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
||||||
<br />
|
<br />
|
||||||
atau: <strong>user@example.com</strong> / <strong>user123</strong>
|
or: <strong>user@example.com</strong> / <strong>user123</strong>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{(login.isError || searchError) && (
|
{(login.isError || searchError) && (
|
||||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
<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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ function LoginPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Divider label="atau" labelPosition="center" />
|
<Divider label="or" labelPosition="center" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
@@ -105,7 +105,7 @@ function LoginPage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<FcGoogle size={18} />}
|
leftSection={<FcGoogle size={18} />}
|
||||||
>
|
>
|
||||||
Login dengan Google
|
Login with Google
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user