From 5002fd1519db870a16e23e123d7292d8b19c046d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 26 May 2026 14:37:28 +0800 Subject: [PATCH 01/16] fix: perbaiki layout table Recent Error Reports di dashboard Kolom App, Version, Reported, dan Status tidak lagi wrap atau terpotong. Tambah horizontal scroll pada container dan minWidth pada table. --- src/frontend/routes/dashboard.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/frontend/routes/dashboard.tsx b/src/frontend/routes/dashboard.tsx index 0a8d8a5..b39c1de 100644 --- a/src/frontend/routes/dashboard.tsx +++ b/src/frontend/routes/dashboard.tsx @@ -198,15 +198,15 @@ function DashboardPage() { - - + +
- App + App Error Message - Version - Reported - Status + Version + Reported + Status @@ -227,7 +227,7 @@ function DashboardPage() { ) : recentErrors.map((error: any) => ( - + {error.app} @@ -237,13 +237,13 @@ function DashboardPage() { - + v{error.version} - + {formatTimeAgo(error.time)} - + Date: Tue, 26 May 2026 14:43:59 +0800 Subject: [PATCH 02/16] fix: perbaiki layout filter Activity Logs agar tidak overflow --- src/frontend/routes/logs.tsx | 65 +++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/src/frontend/routes/logs.tsx b/src/frontend/routes/logs.tsx index 6508011..ad7f570 100644 --- a/src/frontend/routes/logs.tsx +++ b/src/frontend/routes/logs.tsx @@ -1,6 +1,7 @@ import { ActionIcon, Badge, + Box, Container, Group, Loader, @@ -100,39 +101,43 @@ function GlobalLogsPage() { - - { setOperatorId(v ?? 'all'); setPage(1) }} + data={operatorOptions} + style={{ flex: 1, minWidth: 160 }} + clearable + size="sm" + /> + { setDateRange(v); setPage(1) }} + locale="id" + valueFormat="DD MMM YYYY" + clearable + style={{ flex: 2, minWidth: 220 }} + size="sm" + /> + Action type - { setType(v); setPage(1) }} - size="sm" - data={LOG_TYPES.map((t) => ({ label: LOG_TYPE_LABEL[t] ?? t, value: t }))} - /> + + { setType(v); setPage(1) }} + size="sm" + data={LOG_TYPES.map((t) => ({ label: LOG_TYPE_LABEL[t] ?? t, value: t }))} + /> + - + {isLoading && !data ? ( -- 2.49.1 From fe83fd60257f00a6ad6faad6efaecccb0303bff5 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 26 May 2026 14:50:47 +0800 Subject: [PATCH 03/16] fix: perbaiki layout accordion header Bug Reports agar badge status selalu terlihat --- src/frontend/routes/bug-reports.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/frontend/routes/bug-reports.tsx b/src/frontend/routes/bug-reports.tsx index bc67d67..bd7cda3 100644 --- a/src/frontend/routes/bug-reports.tsx +++ b/src/frontend/routes/bug-reports.tsx @@ -700,27 +700,29 @@ function ListErrorsPage() { }} > - + - - - {bug.description} + + + {bug.description} {STATUS_LABEL[bug.status] ?? bug.status} - + {dayjs(bug.createdAt).format('D MMM YYYY, HH:mm')} · {bug.appId?.toUpperCase()} · v{bug.affectedVersion} -- 2.49.1 From fe4ddf686eca3ddd0630cdbb281ba77940b1e495 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 26 May 2026 15:00:47 +0800 Subject: [PATCH 04/16] fix: perbaiki layout tabel User Management agar tidak overflow di layar sempit --- src/frontend/routes/users.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 2134067..9db039e 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -309,14 +309,15 @@ function UsersPage() { )} - + +
Name & Contact - Role - Joined - Actions + Role + Joined + Actions @@ -341,8 +342,8 @@ function UsersPage() { operators.map((user: any) => ( - - + + - + {new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', @@ -440,6 +441,7 @@ function UsersPage() { )}
+
{response?.totalPages > 1 && ( -- 2.49.1 From 501fbde11838d3181452cb5c126e0fd8626d5198 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 26 May 2026 15:11:25 +0800 Subject: [PATCH 05/16] fix: perbaiki layout tabel dan accordion di layar sempit Co-authored-by: amaliadwiy --- src/frontend/components/ErrorDataTable.tsx | 19 ++- src/frontend/routes/apps.$appId.errors.tsx | 12 +- .../apps.$appId.villages.$villageId.tsx | 130 +++++++++--------- 3 files changed, 80 insertions(+), 81 deletions(-) diff --git a/src/frontend/components/ErrorDataTable.tsx b/src/frontend/components/ErrorDataTable.tsx index 69b80b3..21b8e98 100644 --- a/src/frontend/components/ErrorDataTable.tsx +++ b/src/frontend/components/ErrorDataTable.tsx @@ -8,7 +8,6 @@ import { Group, Loader, Paper, - ScrollArea, SimpleGrid, Stack, Table, @@ -74,7 +73,7 @@ export const ErrorDataTable = forwardRef - + @@ -101,15 +100,15 @@ export const ErrorDataTable = forwardRef - + Error Description - Reporter - Version - Reported - Status + Reporter + Version + Reported + Status @@ -149,8 +148,8 @@ export const ErrorDataTable = forwardRef - - + + {dayjs(error.createdAt).format('D MMM YYYY, HH:mm')} @@ -170,7 +169,7 @@ export const ErrorDataTable = forwardRef
-
+
- + - - - {bug.description} + + + {bug.description} {STATUS_LABEL[bug.status] ?? bug.status} - + {dayjs(bug.createdAt).format('D MMM YYYY, HH:mm')} · {bug.appId?.toUpperCase()} · v{bug.affectedVersion} diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index 1fb5aa5..f50a798 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -4,6 +4,7 @@ import { Box, Button, Card, + Grid, Group, Loader, Modal, @@ -218,34 +219,36 @@ function RecentVillageLogs({ villageId }: { villageId: string }) { ) : logs.length === 0 ? ( No recent activity. ) : ( - - - - Time - User - Action - Description - - - - {logs.map((log: any, i: number) => ( - - - {dayjs(log.timestamp).format('D MMM YYYY, HH:mm')} - - - {log.userName || 'Unknown'} - - - {log.action || '-'} - - - {log.desc || '-'} - + +
+ + + Time + User + Action + Description - ))} - -
+ + + {logs.map((log: any, i: number) => ( + + + {dayjs(log.timestamp).format('D MMM YYYY, HH:mm')} + + + {log.userName || 'Unknown'} + + + {log.action || '-'} + + + {log.desc || '-'} + + + ))} + + + )}
) @@ -561,47 +564,42 @@ function VillageDetailPage() { {/* ── Recent Logs + System Info ── */} - - + + - + - - - - - - System Information - - - {[ - { label: 'Date Created', value: village.createdAt }, - { label: 'Created By', value: '-' }, - { label: 'Last Updated', value: village.updatedAt }, - ].map((item, idx, arr) => ( - - {item.label} - {item.value} - - ))} - - - + + + + + + + System Information + + + {[ + { label: 'Date Created', value: village.createdAt }, + { label: 'Created By', value: '-' }, + { label: 'Last Updated', value: village.updatedAt }, + ].map((item, idx, arr) => ( + + {item.label} + {item.value} + + ))} + + + + {/* ── Confirmation Modal ── */} Date: Tue, 26 May 2026 16:28:44 +0800 Subject: [PATCH 06/16] chore: bump version to 0.1.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea98061..f0e8564 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bun-react-template", - "version": "0.1.16", + "version": "0.1.17", "private": true, "type": "module", "scripts": { -- 2.49.1 From 3c188e66d23dc1a1adf0d983e5586dc5408bc983 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:09:46 +0800 Subject: [PATCH 07/16] feat: tambah kolom Last Activity di tabel user management desa-plus --- .../routes/apps.$appId.users.index.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx index c37c43a..9ba0b80 100644 --- a/src/frontend/routes/apps.$appId.users.index.tsx +++ b/src/frontend/routes/apps.$appId.users.index.tsx @@ -34,6 +34,7 @@ import { TbBriefcase, TbCircleCheck, TbCircleX, + TbClock, TbHome2, TbId, TbMail, @@ -68,6 +69,7 @@ interface APIUser { idVillage: string idGroup: string idPosition: string + lastActivity: string | null } interface BaseUserForm { @@ -97,6 +99,15 @@ const REQUIRED_FIELDS = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole' const fetcher = (url: string) => fetch(url).then((res) => res.json()) +function getLastActivityInfo(lastActivity: string | null): { label: string; color: string } { + if (!lastActivity) return { label: 'Never', color: 'gray' } + const days = Math.floor((Date.now() - new Date(lastActivity).getTime()) / (1000 * 60 * 60 * 24)) + if (days < 1) return { label: 'Today', color: 'teal' } + if (days < 7) return { label: `${days}d ago`, color: 'teal' } + if (days <= 30) return { label: `${days}d ago`, color: 'yellow' } + return { label: `${days}d ago`, color: 'red' } +} + interface UserFormFieldsProps { values: BaseUserForm onChange: (updates: Partial) => void @@ -698,11 +709,12 @@ function UsersIndexPage() { {[ - { label: 'User & ID', col: 'name', width: '28%' }, - { label: 'Contact', col: null, width: '25%' }, - { label: 'Organization', col: null, width: '22%' }, - { label: 'Role', col: 'idUserRole', width: '15%' }, + { label: 'User & ID', col: 'name', width: '24%' }, + { label: 'Contact', col: null, width: '21%' }, + { label: 'Organization', col: null, width: '20%' }, + { label: 'Role', col: 'idUserRole', width: '13%' }, { label: 'Status', col: 'isActive', width: '10%' }, + { label: 'Last Activity', col: null, width: '12%' }, ].map(({ label, col, width }) => ( + + {(() => { + const { label, color } = getLastActivityInfo(user.lastActivity) + return ( + + + {label} + + ) + })()} + ))} -- 2.49.1 From 2e64c1c2a6961408cc70352710eb4f87dc639ffa Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:14:40 +0800 Subject: [PATCH 08/16] feat: tambah stale villages alert card di halaman overview desa-plus --- src/frontend/config/api.ts | 1 + src/frontend/routes/apps.$appId.index.tsx | 66 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 76878f0..78b1235 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -32,6 +32,7 @@ export const API_URLS = { if (dateTo) params.set('dateTo', dateTo) return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}` }, + getStaleVillages: (days: 7 | 14 | 30 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/stale-villages?days=${days}`, getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`, getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`, diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx index e5b3b40..b3d98c3 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -4,10 +4,15 @@ import { SummaryCard } from '@/frontend/components/SummaryCard' import { useSession } from '@/frontend/hooks/useAuth' import { ActionIcon, + Anchor, Badge, Button, + Collapse, + Divider, Group, Modal, + Paper, + SegmentedControl, SimpleGrid, Stack, Switch, @@ -25,6 +30,8 @@ import { TbActivity, TbAlertTriangle, TbBuildingCommunity, + TbChevronDown, + TbChevronUp, TbRefresh, TbVersions, } from 'react-icons/tb' @@ -54,12 +61,15 @@ function AppOverviewPage() { const [dailyRange, setDailyRange] = useState<7 | 30 | 90>(7) const [comparisonRange, setComparisonRange] = useState<7 | 30 | 90>(7) + const [staleDays, setStaleDays] = useState<7 | 14 | 30>(7) + const [staleExpanded, { toggle: toggleStale }] = useDisclosure(false) 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(dailyRange) : null, fetcher) const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity(comparisonRange) : null, fetcher) const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher) + const { data: staleRes } = useSWR(isDesaPlus ? API_URLS.getStaleVillages(staleDays) : null, fetcher) const grid = gridRes?.data const dailyData = dailyRes?.data || [] @@ -248,6 +258,62 @@ function AppOverviewPage() { /> + {isDesaPlus && staleRes?.data?.count > 0 && ( + + + + + + {staleRes.data.count} desa tidak ada aktivitas dalam {staleDays} hari terakhir + + + + setStaleDays(Number(v) as 7 | 14 | 30)} + data={[ + { label: '7H', value: '7' }, + { label: '14H', value: '14' }, + { label: '30H', value: '30' }, + ]} + /> + + {staleExpanded ? : } + + + + + + + + {staleRes.data.villages.map((v: { id: string; name: string; daysSince: number | null }) => ( + + navigate({ to: `/apps/${appId}/villages/${v.id}` })} + style={{ cursor: 'pointer' }} + > + {v.name} + + + {v.daysSince === null ? 'Belum pernah ada aktivitas' : `${v.daysSince} hari lalu`} + + + ))} + + + + )} + Analytics -- 2.49.1 From b7aecea43345ff19e2cf1cf978040a44f522c8bf Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:18:08 +0800 Subject: [PATCH 09/16] feat: nama desa di activity logs bisa diklik menuju village detail --- src/frontend/routes/apps.$appId.logs.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/frontend/routes/apps.$appId.logs.tsx b/src/frontend/routes/apps.$appId.logs.tsx index 7e6a671..3bad334 100644 --- a/src/frontend/routes/apps.$appId.logs.tsx +++ b/src/frontend/routes/apps.$appId.logs.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import useSWR from 'swr' import { ActionIcon, + Anchor, Avatar, Badge, Code, @@ -20,7 +21,7 @@ import { } from '@mantine/core' import { useDebouncedValue, useMediaQuery } from '@mantine/hooks' import { DatePickerInput } from '@mantine/dates' -import { createFileRoute, useParams } from '@tanstack/react-router' +import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { TbAlertCircle, TbCalendar, @@ -42,6 +43,7 @@ interface LogEntry { desc: string username: string village: string + idVillage: string } const fetcher = (url: string) => fetch(url).then((res) => res.json()) @@ -81,6 +83,7 @@ function LogTimestamp({ value }: { value: string }) { function AppLogsPage() { const { appId } = useParams({ from: '/apps/$appId/logs' }) + const navigate = useNavigate() const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [searchQuery, setSearchQuery] = useState('') @@ -272,7 +275,15 @@ function AppLogsPage() { - {log.village} + navigate({ to: `/apps/${appId}/villages/${log.idVillage}` })} + style={{ cursor: 'pointer' }} + > + {log.village} + -- 2.49.1 From 75d2ef5b4c20067eebbb0015c2f83c3f4f229fc2 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:19:21 +0800 Subject: [PATCH 10/16] fix: ubah teks stale villages alert ke bahasa inggris --- src/frontend/routes/apps.$appId.index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx index b3d98c3..a16493b 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -270,7 +270,7 @@ function AppOverviewPage() { - {staleRes.data.count} desa tidak ada aktivitas dalam {staleDays} hari terakhir + {staleRes.data.count} {staleRes.data.count === 1 ? 'village' : 'villages'} with no activity in the last {staleDays} days @@ -279,9 +279,9 @@ function AppOverviewPage() { value={String(staleDays)} onChange={(v) => setStaleDays(Number(v) as 7 | 14 | 30)} data={[ - { label: '7H', value: '7' }, - { label: '14H', value: '14' }, - { label: '30H', value: '30' }, + { label: '7D', value: '7' }, + { label: '14D', value: '14' }, + { label: '30D', value: '30' }, ]} /> @@ -305,7 +305,7 @@ function AppOverviewPage() { {v.name} - {v.daysSince === null ? 'Belum pernah ada aktivitas' : `${v.daysSince} hari lalu`} + {v.daysSince === null ? 'No activity yet' : `${v.daysSince}d ago`} ))} -- 2.49.1 From 0e2c97df473d777312e1c479ccf9c6598d20bea6 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:32:56 +0800 Subject: [PATCH 11/16] feat: tambah filter inactive since di halaman user management --- src/frontend/config/api.ts | 5 + .../routes/apps.$appId.users.index.tsx | 119 +++++++++++------- 2 files changed, 80 insertions(+), 44 deletions(-) diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 78b1235..f240f84 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -33,6 +33,11 @@ export const API_URLS = { return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}` }, getStaleVillages: (days: 7 | 14 | 30 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/stale-villages?days=${days}`, + getInactiveUsers: (days: 7 | 14 | 30 = 7, idVillage?: string, page = 1) => { + const params = new URLSearchParams({ days: String(days), page: String(page) }) + if (idVillage) params.set('idVillage', idVillage) + return `${DESA_PLUS_PROXY}/api/monitoring/inactive-users?${params}` + }, getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`, getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`, diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx index 9ba0b80..0e2c1b0 100644 --- a/src/frontend/routes/apps.$appId.users.index.tsx +++ b/src/frontend/routes/apps.$appId.users.index.tsx @@ -240,6 +240,7 @@ function UsersIndexPage() { const [filterRole, setFilterRole] = useState(null) const [filterVillageSearch, setFilterVillageSearch] = useState('') const [filterVillageId, setFilterVillageId] = useState(null) + const [filterInactiveDays, setFilterInactiveDays] = useState(null) const [sortBy, setSortBy] = useState(null) const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') @@ -254,22 +255,21 @@ function UsersIndexPage() { } const isDesaPlus = appId === 'desa-plus' + const isInactiveMode = !!filterInactiveDays const filterStatusParam = filterStatus === 'active' ? 'true' : filterStatus === 'inactive' ? 'false' : undefined const apiUrl = isDesaPlus - ? API_URLS.getUsers( - page, - searchQuery, - filterStatusParam, - filterRole ?? undefined, - filterVillageId ?? undefined, - sortBy ?? undefined, - sortBy ? sortDir : undefined, - ) + ? isInactiveMode + ? API_URLS.getInactiveUsers(Number(filterInactiveDays) as 7 | 14 | 30, filterVillageId ?? undefined, page) + : API_URLS.getUsers(page, searchQuery, filterStatusParam, filterRole ?? undefined, filterVillageId ?? undefined, sortBy ?? undefined, sortBy ? sortDir : undefined) : null const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher) - const users: APIUser[] = response?.data?.user || [] + const users: APIUser[] = isInactiveMode + ? (response?.data?.users || []) + : (response?.data?.user || []) + const totalPages = response?.data?.totalPage ?? 0 + const totalUsers = response?.data?.total ?? 0 useEffect(() => { if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) { @@ -280,7 +280,7 @@ function UsersIndexPage() { useEffect(() => { setPage(1) - }, [filterStatus, filterRole, filterVillageId]) + }, [filterStatus, filterRole, filterVillageId, filterInactiveDays]) const handleClearSearch = () => { setSearch('') @@ -524,7 +524,6 @@ function UsersIndexPage() { onChange={(updates) => setForm((f) => ({ ...f, ...updates }))} {...sharedFormProps} /> - diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx index 0e2c1b0..d43310f 100644 --- a/src/frontend/routes/apps.$appId.users.index.tsx +++ b/src/frontend/routes/apps.$appId.users.index.tsx @@ -35,6 +35,7 @@ import { TbCircleCheck, TbCircleX, TbClock, + TbDownload, TbHome2, TbId, TbMail, @@ -476,6 +477,47 @@ function UsersIndexPage() { } } + const [isExporting, setIsExporting] = useState(false) + + const handleExportCSV = async () => { + setIsExporting(true) + try { + const res = await fetch(API_URLS.exportUsers( + searchQuery, + filterStatusParam, + filterRole ?? undefined, + filterVillageId ?? undefined, + )) + const json = await res.json() + if (!json.success || !json.data?.length) return + + const headers = ['Name', 'NIK', 'Email', 'Phone', 'Gender', 'Role', 'Village', 'Group', 'Position', 'Status', 'Last Activity'] + const rows = json.data.map((r: any) => [ + `"${(r.name ?? '').replace(/"/g, '""')}"`, + r.nik, + r.email, + r.phone, + r.gender, + r.role, + `"${(r.village ?? '').replace(/"/g, '""')}"`, + `"${(r.group ?? '').replace(/"/g, '""')}"`, + `"${(r.position ?? '').replace(/"/g, '""')}"`, + r.status, + r.lastActivity, + ]) + const csv = [headers.join(','), ...rows.map((r: string[]) => r.join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `users-${new Date().toISOString().slice(0, 10)}.csv` + a.click() + URL.revokeObjectURL(url) + } finally { + setIsExporting(false) + } + } + const getRoleColor = (role: string) => { const r = role.toLowerCase() if (r.includes('super')) return 'red' @@ -603,15 +645,28 @@ function UsersIndexPage() { : `${totalUsers} users registered in the Desa+ system`} - + + + + {/* Filter */} -- 2.49.1 From a2b3c9bc855d33b1e7c3b8d532904a9cb0095939 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 15:14:54 +0800 Subject: [PATCH 14/16] feat: tambah section inactive users di halaman detail desa --- .../apps.$appId.villages.$villageId.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index 970fc52..c695c99 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -8,7 +8,9 @@ import { Group, Loader, Modal, + Pagination, Paper, + ScrollArea, SegmentedControl, SimpleGrid, Stack, @@ -40,6 +42,7 @@ import { TbPower, TbTestPipe, TbUser, + TbUserOff, TbUsers, TbUsersGroup, TbWifi @@ -321,6 +324,116 @@ function RecentVillageLogs({ villageId }: { villageId: string }) { ) } +// ── Inactive Users ──────────────────────────────────────────────────────────── + +function InactiveVillageUsers({ villageId }: { villageId: string }) { + const [days, setDays] = useState<7 | 14 | 30>(7) + const [page, setPage] = useState(1) + + const { data: response, isLoading } = useSWR( + API_URLS.getInactiveUsers(days, villageId, page), + fetcher + ) + + const users: any[] = response?.data?.users || [] + const totalPages: number = response?.data?.totalPage ?? 0 + const total: number = response?.data?.total ?? 0 + + return ( + + + + + + + + Inactive Users + + {isLoading ? 'Loading...' : `${total} users with no activity in the last ${days} days`} + + + + { setDays(Number(v) as 7 | 14 | 30); setPage(1) }} + data={[ + { label: '7D', value: '7' }, + { label: '14D', value: '14' }, + { label: '30D', value: '30' }, + ]} + /> + + + {isLoading ? ( + + + + ) : users.length === 0 ? ( + + + No inactive users in this period. + + ) : ( + + + + + + Name + Role + Group / Position + Status + Last Activity + + + + {users.map((u: any) => ( + + + + {u.name} + {u.email} + + + + + {u.role} + + + + {u.group}{u.position ? ` · ${u.position}` : ''} + + + + {u.isActive ? 'Active' : 'Inactive'} + + + + {u.daysSince === null ? ( + Never + ) : ( + 30 ? 'red.5' : u.daysSince > 7 ? 'yellow.5' : 'dimmed'}> + {u.daysSince}d ago + + )} + + + ))} + +
+
+ {totalPages > 1 && ( + + + + )} +
+ )} +
+ ) +} + // ── Main Page ───────────────────────────────────────────────────────────────── function VillageDetailPage() { @@ -671,6 +784,9 @@ function VillageDetailPage() { + {/* ── Inactive Users ── */} + + {/* ── Confirmation Modal ── */} Date: Thu, 28 May 2026 15:39:57 +0800 Subject: [PATCH 15/16] feat: PDF report lengkap dengan summary, top 5, attention villages, trend, peak hours - PDF report memuat 6 ringkasan kartu: desa aktif/nonaktif/no-activity, total aktivitas, pengguna aktif/nonaktif - Section Top 5 desa paling aktif dengan progress bar dan medal emoji - Section Villages Needing Attention untuk desa nonaktif atau tanpa aktivitas - Tabel semua desa dengan kolom trend vs periode sebelumnya - Section Peak Activity Hours dalam layout dua kolom bersama Top 5 - Menggunakan window.open + HTML string untuk output yang bersih dan terbaca --- bun.lock | 46 ++++ package.json | 2 + src/frontend/config/api.ts | 2 + src/frontend/routes/apps.$appId.index.tsx | 293 +++++++++++++++++++++- 4 files changed, 330 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index ca4b997..2feb3a8 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,8 @@ "dayjs": "^1.11.20", "elkjs": "^0.9.3", "elysia": "^1.4.28", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "minio": "^8.0.7", "postcss": "^8.5.8", "postcss-preset-mantine": "^1.18.0", @@ -352,10 +354,16 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], @@ -406,6 +414,8 @@ "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], @@ -438,6 +448,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="], + "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -472,10 +484,14 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -544,6 +560,8 @@ "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -616,6 +634,8 @@ "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], @@ -626,6 +646,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-type": ["file-type@22.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -674,6 +696,8 @@ "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -690,6 +714,8 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], @@ -724,6 +750,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -802,6 +830,8 @@ "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -816,6 +846,8 @@ "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -866,6 +898,8 @@ "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -906,6 +940,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -914,6 +950,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -962,6 +1000,8 @@ "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], @@ -984,6 +1024,8 @@ "sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="], + "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], + "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=="], @@ -996,6 +1038,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -1046,6 +1090,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], diff --git a/package.json b/package.json index f0e8564..f472192 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "dayjs": "^1.11.20", "elkjs": "^0.9.3", "elysia": "^1.4.28", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "minio": "^8.0.7", "postcss": "^8.5.8", "postcss-preset-mantine": "^1.18.0", diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index f7d022f..3827227 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -89,6 +89,8 @@ export const API_URLS = { if (dateTo) params.set('dateTo', dateTo) return `${DESA_PLUS_PROXY}/api/monitoring/export-logs?${params}` }, + getVillageReport: (range: 7 | 30 | 90 = 7) => + `${DESA_PLUS_PROXY}/api/monitoring/village-report?range=${range}`, exportUsers: (search: string, isActive?: string, idUserRole?: string, idVillage?: string) => { const params = new URLSearchParams({ search }) if (isActive) params.set('isActive', isActive) diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx index a16493b..90a86a5 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -32,6 +32,7 @@ import { TbBuildingCommunity, TbChevronDown, TbChevronUp, + TbFileText, TbRefresh, TbVersions, } from 'react-icons/tb' @@ -52,6 +53,7 @@ function AppOverviewPage() { const { data: session } = useSession() const isDeveloper = session?.user?.role === 'DEVELOPER' const errorTableRef = useRef(null) + const [isExporting, setIsExporting] = useState(false) const [latestVersion, setLatestVersion] = useState('') const [minVersion, setMinVersion] = useState('') @@ -130,6 +132,256 @@ function AppOverviewPage() { } } + const handleDownloadPDF = async () => { + setIsExporting(true) + try { + const [reportRes, peakRes] = await Promise.all([ + fetch(API_URLS.getVillageReport(comparisonRange as 7 | 30 | 90)).then(r => r.json()), + fetch(API_URLS.getPeakHours()).then(r => r.json()), + ]) + if (!reportRes.success) return + + const { villages, generatedAt } = reportRes.data + const peakHours: { hour: number; label: string; count: number }[] = peakRes?.data?.hours ?? [] + const peakHour: { label: string; count: number } | null = peakRes?.data?.peak ?? null + const appName = isDesaPlus ? 'Desa+' : appId + + // ── Aggregates ───────────────────────────────────── + const totalActive = villages.filter((v: any) => v.isActive).length + const totalInactive = villages.filter((v: any) => !v.isActive).length + const totalStale = villages.filter((v: any) => v.activityCount === 0).length + const totalActivity = villages.reduce((s: number, v: any) => s + v.activityCount, 0) + const totalActiveUsers = villages.reduce((s: number, v: any) => s + v.activeUsers, 0) + const totalInactiveUsers = villages.reduce((s: number, v: any) => s + v.inactiveUsers, 0) + + const top5 = villages.slice(0, 5) + const maxActivity = top5[0]?.activityCount || 1 + const needsAttention = villages.filter((v: any) => !v.isActive || v.activityCount === 0) + + // ── Helpers ──────────────────────────────────────── + const trendBadge = (trend: number) => { + if (trend > 0) return `▲ +${trend}%` + if (trend < 0) return `▼ ${trend}%` + return `— 0%` + } + const statusBadge = (active: boolean) => + ` + ${active ? 'Active' : 'Inactive'}` + const lastActivityCell = (v: any) => v.lastActivity + ? `${v.lastActivity}
${v.daysSince}d ago` + : 'No activity' + + // ── Section: Top 5 ──────────────────────────────── + const top5Rows = top5.map((v: any, i: number) => ` + + + ${i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i + 1}`} + + ${v.name} + +
+
+
+ + ${v.activityCount.toLocaleString()} + ${trendBadge(v.trend)} + `).join('') + + // ── Section: Needs Attention ─────────────────────── + const attentionRows = needsAttention.length === 0 + ? 'All villages are active and have activity in this period.' + : needsAttention.map((v: any, i: number) => ` + + ${v.name} + ${statusBadge(v.isActive)} + ${v.activeUsers + v.inactiveUsers} + + ${!v.isActive + ? 'Village inactive' + : 'No activity in period'} + + ${lastActivityCell(v)} + `).join('') + + // ── Section: All Villages ───────────────────────── + const allRows = villages.map((v: any, i: number) => ` + + ${i + 1} + + ${v.name} + ${v.perbekel !== '-' ? `
Perbekel: ${v.perbekel}` : ''} + + ${statusBadge(v.isActive)} + ${v.activeUsers} + ${v.inactiveUsers} + ${v.activityCount.toLocaleString()} + ${trendBadge(v.trend)} + ${lastActivityCell(v)} + `).join('') + + // ── Section: Peak Hours ─────────────────────────── + const peakMax = Math.max(...peakHours.map(h => h.count), 1) + const peakRows = peakHours.filter(h => h.count > 0).map((h, i) => ` + + ${h.label} + +
+
+
+ + ${h.count.toLocaleString()} + `).join('') + + // ── Build HTML ──────────────────────────────────── + const html = ` + + + + ${appName} — Village Report + + + + +
+

${appName} — Village Monitoring Report

+

Generated: ${generatedAt}

+

Period: last ${comparisonRange} days  ·  Compared to previous ${comparisonRange} days

+
+ +
+
+
Active Villages
+
${totalActive}
+
of ${villages.length} total
+
+
+
Inactive Villages
+
${totalInactive}
+
not operational
+
+
+
No Activity
+
${totalStale}
+
in this period
+
+
+
Total Activity
+
${totalActivity.toLocaleString()}
+
last ${comparisonRange} days
+
+
+
Active Users
+
${totalActiveUsers.toLocaleString()}
+
across all villages
+
+
+
Inactive Users
+
${totalInactiveUsers.toLocaleString()}
+
across all villages
+
+
+ +
+
+
+

Top 5 Most Active Villages

+ + + + + + + + + + + ${top5Rows} +
#VillageActivityCountvs Prev
+
+
+

Peak Activity Hours

+ ${peakHour ? `

Busiest hour: ${peakHour.label} (${peakHour.count.toLocaleString()} activities)

` : ''} + + + ${peakRows || ''} +
HourDistributionCount
No data
+
+
+
+ +
+

Villages Needing Attention (${needsAttention.length})

+ ${needsAttention.length === 0 + ? '

All villages are active and have activity in this period.

' + : ` + + + + + + + + + + ${attentionRows} +
VillageStatusTotal UsersReasonLast Activity
`} +
+ +
+

All Villages — ${villages.length} Villages

+ + + + + + + + + + + + + + ${allRows} +
#Village / PerbekelStatusActive UsersInactive UsersActivity (${comparisonRange}D)vs Prev PeriodLast Activity
+
+ + + +