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/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..a4112fe
Binary files /dev/null and b/bun.lockb differ
diff --git a/package.json b/package.json
index eddf26e..d0c4921 100644
--- a/package.json
+++ b/package.json
@@ -22,10 +22,12 @@
},
"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",
"@mantine/hooks": "^8.3.18",
+ "@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
@@ -36,7 +38,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/App.tsx b/src/frontend/App.tsx
index 838a693..453a205 100644
--- a/src/frontend/App.tsx
+++ b/src/frontend/App.tsx
@@ -1,5 +1,7 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
+import '@mantine/notifications/styles.css'
+import { Notifications } from '@mantine/notifications'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
@@ -61,6 +63,7 @@ export function App() {
<>
+
diff --git a/src/frontend/components/DashboardCharts.tsx b/src/frontend/components/DashboardCharts.tsx
index c2ddf93..bd67156 100644
--- a/src/frontend/components/DashboardCharts.tsx
+++ b/src/frontend/components/DashboardCharts.tsx
@@ -11,25 +11,12 @@ import {
import { LineChart, BarChart } from '@mantine/charts'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
-const activityData = [
- { date: 'Mar 26', logs: 1200 },
- { date: 'Mar 27', logs: 1900 },
- { date: 'Mar 28', logs: 1540 },
- { date: 'Mar 29', logs: 2400 },
- { date: 'Mar 30', logs: 2100 },
- { date: 'Mar 31', logs: 3200 },
- { date: 'Apr 01', logs: 3800 },
-]
+interface ChartProps {
+ data?: any[]
+ isLoading?: boolean
+}
-const villageComparisonData = [
- { village: 'Sukatani', activity: 4500 },
- { village: 'Sukamaju', activity: 3800 },
- { village: 'Bojong Gede', activity: 3200 },
- { village: 'Beji', activity: 2800 },
- { village: 'Tapos', activity: 2400 },
-]
-
-export function VillageActivityLineChart() {
+export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
@@ -46,14 +33,14 @@ export function VillageActivityLineChart() {
}>
- Growing
+ {isLoading ? '...' : 'Live'}
USAGE COMPARISON BETWEEN VILLAGES
- Top 5 most active village deployments
+ Most active village deployments
@@ -97,7 +84,7 @@ export function VillageComparisonBarChart() {
- {/* Custom SVG Gradient definitions for Premium SaaS look */}
diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts
new file mode 100644
index 0000000..c5ee969
--- /dev/null
+++ b/src/frontend/config/api.ts
@@ -0,0 +1,21 @@
+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}`,
+ getUsers: (page: number, search: string) =>
+ `${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
+ getLogsAllVillages: (page: number, search: string) =>
+ `${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
+ getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
+ getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
+ getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
+ postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
+ createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
+}
diff --git a/src/frontend/config/appMenus.ts b/src/frontend/config/appMenus.ts
index 2af209b..fb39f1b 100644
--- a/src/frontend/config/appMenus.ts
+++ b/src/frontend/config/appMenus.ts
@@ -1,5 +1,5 @@
import { IconType } from 'react-icons'
-import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard, TbBuilding } from 'react-icons/tb'
+import { TbAlertTriangle, TbBuilding, TbChartBar, TbCreditCard, TbHistory, TbPackage, TbShoppingCart, TbUsers } from 'react-icons/tb'
export interface MenuItem {
value: string
@@ -23,6 +23,7 @@ export const APP_CONFIGS: Record = {
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
+ { value: 'users', label: 'Users', icon: TbUsers, to: '/apps/desa-plus/users' },
],
},
'e-commerce': {
diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx
index 4112379..acb394f 100644
--- a/src/frontend/routes/apps.$appId.index.tsx
+++ b/src/frontend/routes/apps.$appId.index.tsx
@@ -1,3 +1,5 @@
+import { useEffect, useState } from 'react'
+import { notifications } from '@mantine/notifications'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard'
@@ -13,10 +15,12 @@ import {
TextInput,
Switch,
Badge,
- Textarea
+ Textarea,
+ Skeleton
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
+import useSWR from 'swr'
import {
TbActivity,
TbAlertTriangle,
@@ -24,125 +28,200 @@ import {
TbRefresh,
TbVersions
} from 'react-icons/tb'
+import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/')({
component: AppOverviewPage,
})
+const fetcher = (url: string) => fetch(url).then((res) => res.json())
+
function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
+ // Form State
+ const [latestVersion, setLatestVersion] = useState('')
+ const [minVersion, setMinVersion] = useState('')
+ const [messageUpdate, setMessageUpdate] = useState('')
+ const [maintenance, setMaintenance] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // Data Fetching
+ const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
+ const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
+ const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
+
+ const grid = gridRes?.data
+ const dailyData = dailyRes?.data || []
+ const comparisonData = comparisonRes?.data || []
+
+ // Initialize form when data loads or modal opens
+ useEffect(() => {
+ if (grid?.version && versionModalOpened) {
+ setLatestVersion(grid.version.mobile_latest_version || '')
+ setMinVersion(grid.version.mobile_minimum_version || '')
+ setMessageUpdate(grid.version.mobile_message_update || '')
+ setMaintenance(grid.version.mobile_maintenance === 'true')
+ }
+ }, [grid, versionModalOpened])
+
+ const handleRefresh = () => {
+ mutateGrid()
+ mutateDaily()
+ mutateComparison()
+ }
+
+ const handleSaveVersion = async () => {
+ setIsSaving(true)
+ try {
+ const response = await fetch(API_URLS.postVersionUpdate(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mobile_latest_version: latestVersion,
+ mobile_minimum_version: minVersion,
+ mobile_maintenance: maintenance,
+ mobile_message_update: messageUpdate,
+ }),
+ })
+
+ if (response.ok) {
+ notifications.show({
+ title: 'Update Successful',
+ message: 'Application version information has been updated.',
+ color: 'teal',
+ })
+ mutateGrid()
+ closeVersionModal()
+ } else {
+ notifications.show({
+ title: 'Update Failed',
+ message: 'Failed to update version information. Please check your data.',
+ color: 'red',
+ })
+ }
+ } catch (error) {
+ notifications.show({
+ title: 'Network Error',
+ message: 'Could not connect to the server. Please try again later.',
+ color: 'red',
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
return (
<>
-
-
+ setLatestVersion(e.currentTarget.value)}
+ />
+ setMinVersion(e.currentTarget.value)}
+ />
- {/* 🔝 HEADER SECTION */}
- {/* */}
-
-
- Overview
- Last updated: Just now
-
+
+
+ Overview
+ Detailed metrics for {isDesaPlus ? 'Desa+' : appId}
+
-
- {/* }
- radius="md"
- w={140}
- /> */}
-
-
-
- {/* }
- >
- Add Filter
- */}
+ {/*
+
+
+
+ */}
-
- {/* */}
- {/* 📊 1. SUMMARY CARDS */}
-
-
-
-
- Min. Version
- v1.0.0
-
-
- Maintenance
- False
-
-
-
-
- navigate({ to: `/apps/${appId}/villages` })}
- >
-
- Nonactive Villages
- 24
-
-
-
-
+
+
+
+
+ Min. Version
+ {grid?.version?.mobile_minimum_version || '-'}
+
+
+ Maintenance
+
+ {grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
+
+
+
+
- {/* 📈 📊 2 & 3. CHARTS GRID */}
-
-
-
-
+ 0 } : undefined}
+ />
- {/* 🐞 4. LATEST ERROR REPORTS */}
-
-
+ navigate({ to: `/apps/${appId}/villages` })}
+ >
+
+ Nonactive Villages
+ {grid?.village?.inactive || 0}
+
+
+
+
+
+
+
+
+
+
+
+
+
>
)
}
diff --git a/src/frontend/routes/apps.$appId.logs.tsx b/src/frontend/routes/apps.$appId.logs.tsx
index 81b4bcf..16c9cf7 100644
--- a/src/frontend/routes/apps.$appId.logs.tsx
+++ b/src/frontend/routes/apps.$appId.logs.tsx
@@ -1,6 +1,7 @@
+import { useState } from 'react'
+import useSWR from 'swr'
import {
Badge,
- Container,
Group,
Stack,
Text,
@@ -8,116 +9,244 @@ import {
Paper,
Table,
TextInput,
- Select,
ActionIcon,
- Tooltip,
Avatar,
Code,
- Button
+ Button,
+ Box,
+ Pagination,
+ ThemeIcon,
+ ScrollArea,
+ Container,
} from '@mantine/core'
+import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
-import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
+import {
+ TbSearch,
+ TbDownload,
+ TbX,
+ TbHistory,
+ TbCalendar,
+ TbUser,
+ TbHome2
+} from 'react-icons/tb'
+import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/logs')({
component: AppLogsPage,
})
-const mockLogs = [
- { id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
- { id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
- { id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
- { id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
- { id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
-]
+interface LogEntry {
+ id: string
+ createdAt: string
+ action: string
+ desc: string
+ username: string
+ village: string
+}
+
+const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' })
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [searchQuery, setSearchQuery] = useState('')
+
const isDesaPlus = appId === 'desa-plus'
+ const isMobile = useMediaQuery('(max-width: 768px)')
+
+ const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null
+ const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
+ const logs: LogEntry[] = response?.data?.log || []
+
+ const handleSearchChange = (val: string) => {
+ setSearch(val)
+ if (val.length >= 3 || val.length === 0) {
+ setSearchQuery(val)
+ setPage(1)
+ }
+ }
+
+ const handleClearSearch = () => {
+ setSearch('')
+ setSearchQuery('')
+ setPage(1)
+ }
+
+ const getActionColor = (action: string) => {
+ const a = action.toUpperCase()
+ if (a === 'LOGIN') return 'blue'
+ if (a === 'LOGOUT') return 'gray'
+ if (a === 'CREATE') return 'teal'
+ if (a === 'UPDATE') return 'orange'
+ if (a === 'DELETE') return 'red'
+ return 'brand-blue'
+ }
+
+ if (!isDesaPlus) {
+ return (
+
+
+
+ Activity Logs
+ This feature is currently customized for Desa+. Other apps coming soon.
+
+
+ )
+ }
return (
-
-
-
- {isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}
- Detailed audit trail of all actions performed within the application instances.
-
-
- } radius="md">Export XLS
-
-
+
+
+
+
+
+
+
+
+
+ Activity Logs
+
+
+ {isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
+
+
+ }
+ radius="md"
+ size="md"
+ >
+ Export
+
+
-
-
}
+ placeholder="Search action or village..."
+ leftSection={}
+ size="md"
+ rightSection={
+ search ? (
+
+
+
+ ) : null
+ }
+ value={search}
+ onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
+ style={{ maxWidth: 500 }}
+ ml={40}
/>
- }
+
+
+
+ {isLoading ? (
+
+ Fetching activity logs...
+
+ ) : error ? (
+
+ Failed to load logs from API.
+
+ ) : logs.length === 0 ? (
+
+
+ No activity found for this search.
+
+ ) : (
+
+
+
+
+
+ Timestamp
+ User & Village
+ Action
+ Description
+
+
+
+ {logs.map((log) => (
+
+
+
+
+
+
+
+
+ {log.createdAt.split(' ').slice(1).join(' ')}
+
+
+ {log.createdAt.split(' ')[0]}
+
+
+
+
+
+
+
+
+ {log.username.charAt(0)}
+
+ {log.username}
+
+
+
+ {log.village}
+
+
+
+
+
+ {log.action}
+
+
+
+
+ {log.desc}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {!isLoading && !error && response?.data?.totalPage > 0 && (
+
+
-
-
-
-
- Type
- Village / Instance
- Activity Name
- Operator
- Timestamp
- Status
-
-
-
- {mockLogs.map((log) => (
-
-
-
- {log.type}
-
-
-
- {log.village}
-
-
- {log.activity}
-
-
-
- {log.operator[0]}
- {log.operator}
-
-
-
- {log.time}
-
-
-
- {log.status}
-
-
-
- ))}
-
-
-
+ )}
)
}
diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx
new file mode 100644
index 0000000..3db42f5
--- /dev/null
+++ b/src/frontend/routes/apps.$appId.users.index.tsx
@@ -0,0 +1,299 @@
+import { useState } from 'react'
+import useSWR from 'swr'
+import {
+ Badge,
+ Container,
+ Group,
+ Stack,
+ Text,
+ Title,
+ Paper,
+ Button,
+ ActionIcon,
+ TextInput,
+ Table,
+ Avatar,
+ Box,
+ Pagination,
+ ThemeIcon,
+ ScrollArea,
+ Tooltip,
+} from '@mantine/core'
+import { useMediaQuery } from '@mantine/hooks'
+import { createFileRoute, useParams } from '@tanstack/react-router'
+import {
+ TbPlus,
+ TbSearch,
+ TbUsers,
+ TbX,
+ TbMail,
+ TbPhone,
+ TbId,
+ TbBriefcase,
+ TbHome2,
+ TbCircleCheck,
+ TbCircleX,
+} from 'react-icons/tb'
+import { API_URLS } from '../config/api'
+
+export const Route = createFileRoute('/apps/$appId/users/')({
+ component: UsersIndexPage,
+})
+
+interface APIUser {
+ id: string
+ name: string
+ nik: string
+ phone: string
+ email: string
+ isWithoutOTP: boolean
+ isActive: boolean
+ role: string
+ village: string
+ group: string
+ position?: string
+}
+
+const fetcher = (url: string) => fetch(url).then((res) => res.json())
+
+function UsersIndexPage() {
+ const { appId } = useParams({ from: '/apps/$appId/users/' })
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+ const [searchQuery, setSearchQuery] = useState('')
+
+ const isDesaPlus = appId === 'desa-plus'
+ const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
+
+ const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
+ const users: APIUser[] = response?.data?.user || []
+
+ const handleSearchChange = (val: string) => {
+ setSearch(val)
+ if (val.length >= 3 || val.length === 0) {
+ setSearchQuery(val)
+ setPage(1)
+ }
+ }
+
+ const handleClearSearch = () => {
+ setSearch('')
+ setSearchQuery('')
+ setPage(1)
+ }
+
+ const getRoleColor = (role: string) => {
+ const r = role.toLowerCase()
+ if (r.includes('super')) return 'red'
+ if (r.includes('admin')) return 'brand-blue'
+ if (r.includes('developer')) return 'violet'
+ return 'gray'
+ }
+
+ const isMobile = useMediaQuery('(max-width: 768px)')
+
+ if (!isDesaPlus) {
+ return (
+
+
+
+ User Management
+ This feature is currently customized for Desa+. Other apps coming soon.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ User Management
+
+
+ {isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
+
+
+ }
+ radius="md"
+ size="md"
+ >
+ Add User
+
+
+
+ }
+ size="md"
+ rightSection={
+ search ? (
+
+
+
+ ) : null
+ }
+ value={search}
+ onChange={(e) => handleSearchChange(e.currentTarget.value)}
+ radius="md"
+ style={{ maxWidth: 500 }}
+ ml={40}
+ />
+
+
+
+ {isLoading ? (
+
+ Loading user data...
+
+ ) : error ? (
+
+ Failed to load data from API.
+
+ ) : users.length === 0 ? (
+
+
+ No users match your criteria.
+
+ ) : (
+
+
+
+
+
+ User & ID
+ Contact Detail
+ Organization
+ Role
+ Status
+
+
+
+ {users.map((user) => (
+
+
+
+
+ {user.name.charAt(0)}
+
+
+ {user.name}
+
+
+ {user.nik}
+
+
+
+
+
+
+
+
+
+
+ {user.email}
+
+
+
+
+
+ {user.phone}
+
+
+
+
+
+
+
+
+
+ {user.village}
+
+
+
+
+
+ {user.group} · {user.position || 'Staff'}
+
+
+
+
+
+ {user.role}
+
+
+
+
+
+ {user.isActive ? (
+
+ ) : (
+
+ )}
+
+ {user.isActive ? 'ACTIVE' : 'INACTIVE'}
+
+
+ {user.isWithoutOTP && (
+
+ NO OTP
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {!isLoading && !error && response?.data?.totalPage > 0 && (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/frontend/routes/apps.$appId.users.tsx b/src/frontend/routes/apps.$appId.users.tsx
new file mode 100644
index 0000000..fd6a7d1
--- /dev/null
+++ b/src/frontend/routes/apps.$appId.users.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Outlet } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/apps/$appId/users')({
+ component: UsersLayout,
+})
+
+function UsersLayout() {
+ return
+}
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.
} onClick={goBack}>
- Kembali ke Daftar
+ Back to List
)
}
- 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 */}
: }
+ color={village.isActive ? 'red' : 'green'}
+ leftSection={village.isActive ? : }
onClick={() => alert(`Toggle status for ${village.name}`)}
radius="md"
>
- {village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
+ {village.isActive ? 'Deactivate' : 'Active'}
@@ -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 +119,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}
-
+ */}
}
styles={{ root: { fontSize: 12 } }}
>
- Lihat Detail
+ View Details
)
}
-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 +190,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 +218,105 @@ function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
+ const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false)
+ const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
+ const [searchQuery, setSearchQuery] = useState('')
+
+ // Form State
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [form, setForm] = useState({
+ name: '',
+ desc: '',
+ username: '',
+ phone: '',
+ nik: '',
+ email: '',
+ gender: ''
+ })
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, mutate } = 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)
+ }
+
+ const handleCreateVillage = async () => {
+ const requiredFields = ['name', 'desc', 'username', 'phone', 'nik', 'email', 'gender'] as const
+ const isFormValid = requiredFields.every(field => !!form[field])
+
+ if (!isFormValid) {
+ notifications.show({
+ title: 'Validation Error',
+ message: 'All fields are required to register a new village.',
+ color: 'red'
+ })
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const res = await fetch(API_URLS.createVillages(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(form)
+ })
+
+ if (res.ok) {
+ notifications.show({
+ title: 'Success',
+ message: 'Village has been successfully registered.',
+ color: 'teal'
+ })
+ mutate() // Refresh list
+ closeCreateModal()
+ setForm({
+ name: '',
+ desc: '',
+ username: '',
+ phone: '',
+ nik: '',
+ email: '',
+ gender: ''
+ })
+ } else {
+ notifications.show({
+ title: 'Error',
+ message: 'Failed to create village. Please try again.',
+ color: 'red'
+ })
+ }
+ } catch (e) {
+ notifications.show({
+ title: 'Network Error',
+ message: 'Unable to reach API server.',
+ color: 'red'
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
if (!isDesaPlus) {
return (
@@ -298,11 +331,105 @@ function AppVillagesIndexPage() {
return (
+ Register New Village}
+ radius="xl"
+ size="lg"
+ >
+
+
+
+ Village Data
+
+
+ setForm(prev => ({ ...prev, name: e.currentTarget.value }))}
+ />
+
+
+
+
+
+
+
+ setForm(prev => ({ ...prev, username: e.currentTarget.value }))}
+ />
+ setForm(prev => ({ ...prev, nik: e.currentTarget.value }))}
+ />
+
+
+
+ setForm(prev => ({ ...prev, email: e.currentTarget.value }))}
+ />
+ setForm(prev => ({ ...prev, phone: e.currentTarget.value }))}
+ />
+
+
+
+
+
+
+
+
- Daftar Desa
+ Village List
- {filtered.length} desa terdaftar dalam platform Desa+
+ {isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`}
}
radius="md"
+ onClick={openCreateModal}
>
- Tambah Desa
+ Create New Village
}
+ 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 +470,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
-
+
}
>
- Login dengan Google
+ Login with Google