From ed9c1da878ee61e5adba85ce3286b3ddf303da2e Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 15:49:10 +0800 Subject: [PATCH] feat: tambahkan PDF report per desa di halaman detail desa - Tombol Download PDF di sebelah tombol Edit dan Deactivate - Laporan memuat: header desa (nama, perbekel, status, tanggal), 4 summary card (users, groups, divisions, projects), activity trend 14 hari, peak hours, 10 log aktivitas terakhir, dan tabel inactive users 7 hari terakhir --- .../apps.$appId.villages.$villageId.tsx | 226 +++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index c695c99..c39d3d8 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -36,6 +36,7 @@ import { TbChartBar, TbClock, TbEdit, + TbFileText, TbHome2, TbLayoutKanban, TbMapPin, @@ -450,6 +451,7 @@ function VillageDetailPage() { const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false) const [isUpdating, setIsUpdating] = useState(false) const [isEditing, setIsEditing] = useState(false) + const [isExporting, setIsExporting] = useState(false) const [editForm, setEditForm] = useState({ name: '', desc: '', isDummy: false }) const village = infoRes?.data @@ -523,6 +525,216 @@ function VillageDetailPage() { } } + const handleDownloadPDF = async () => { + if (!village || !stats) return + setIsExporting(true) + try { + const [activityRes, peakRes, logsRes, inactiveRes] = await Promise.all([ + fetch(API_URLS.graphLogVillages(villageId, 'daily')).then(r => r.json()), + fetch(API_URLS.getPeakHours(villageId)).then(r => r.json()), + fetch(API_URLS.getRecentVillageLogs(villageId)).then(r => r.json()), + fetch(API_URLS.getInactiveUsers(7, villageId, 1)).then(r => r.json()), + ]) + + const activityData: { label: string; aktivitas: number }[] = activityRes?.data || [] + const peakHours: { hour: number; label: string; count: number }[] = peakRes?.data?.hours || [] + const peak: { label: string; count: number } | null = peakRes?.data?.peak || null + const recentLogs: { timestamp: string; userName: string; action: string; desc: string }[] = logsRes?.data || [] + const inactiveUsers: any[] = inactiveRes?.data?.users || [] + const totalInactive: number = inactiveRes?.data?.total ?? 0 + + const generatedAt = dayjs().format('DD MMM YYYY HH:mm') + + const maxActivity = Math.max(...activityData.map(d => d.aktivitas), 1) + const activityRows = activityData.map((d, i) => ` + + ${d.label} + +
+
+
+ + ${d.aktivitas.toLocaleString()} + `).join('') + + 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('') + + const actionColor = (action: string) => { + const a = action.toUpperCase() + if (a === 'LOGIN') return '#059669' + if (a === 'LOGOUT') return '#6b7280' + if (a === 'CREATE') return '#2563eb' + if (a === 'UPDATE') return '#d97706' + if (a === 'DELETE') return '#dc2626' + return '#374151' + } + + const logRows = recentLogs.map((log, i) => ` + + ${dayjs(log.timestamp).format('DD MMM YYYY HH:mm')} + ${log.userName} + ${log.action} + ${log.desc || '-'} + `).join('') + + const inactiveRows = inactiveUsers.length === 0 + ? 'No inactive users in this period.' + : inactiveUsers.map((u, i) => ` + + + ${u.name}
+ ${u.email} + + ${u.role} + ${u.group || '-'}${u.position ? ` · ${u.position}` : ''} + + ${u.daysSince === null + ? 'Never' + : `${u.daysSince}d ago`} + + `).join('') + + const html = ` + + + + ${village.name} — Village Report + + + + +
+

${village.name}

+

Village Head (Perbekel): ${village.perbekel || '-'}

+

Status: ${village.isActive ? 'Active' : 'Inactive'}${village.isDummy ? '  ·  Dummy Data' : ''}

+

Created: ${village.createdAt}  ·  Last Updated: ${village.updatedAt || '-'}

+

Generated: ${generatedAt}

+
+ +
+
+
Active Users
+
${stats.user.active.toLocaleString()}
+
${stats.user.nonActive} inactive
+
+
+
Groups
+
${stats.group.active.toLocaleString()}
+
${stats.group.nonActive} inactive
+
+
+
Divisions
+
${stats.division.active.toLocaleString()}
+
${stats.division.nonActive} inactive
+
+
+
Projects
+
${stats.project.active.toLocaleString()}
+
${stats.project.nonActive} inactive
+
+
+ +
+
+
+

Activity Trend — Last 14 Days

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

No activity data available.

' + : ` + + ${activityRows} +
DateDistributionCount
`} +
+
+

Peak Activity Hours

+ ${peak && peak.count > 0 + ? `

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

` + : '

No peak data available.

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

Recent Activity — Last 10 Logs

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

No recent activity recorded.

' + : ` + + + + + + + + + ${logRows} +
TimeUserActionDescription
`} +
+ +
+

Inactive Users — No Activity in Last 7 Days (${totalInactive}${totalInactive > inactiveUsers.length ? `, showing first ${inactiveUsers.length}` : ''})

+ + + + + + + + + + ${inactiveRows} +
Name / EmailRoleGroup / PositionLast Activity
+
+ + + +