Files
monitoring-app/src/frontend/routes/apps.$appId.villages.$villageId.tsx
amaliadwiy ed9c1da878 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
2026-05-28 15:49:10 +08:00

1093 lines
40 KiB
TypeScript

import { AreaChart, BarChart } from '@mantine/charts'
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Loader,
Modal,
Pagination,
Paper,
ScrollArea,
SegmentedControl,
SimpleGrid,
Stack,
Switch,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import dayjs from 'dayjs'
import { useState } from 'react'
import {
TbArrowLeft,
TbBuildingCommunity,
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbClock,
TbEdit,
TbFileText,
TbHome2,
TbLayoutKanban,
TbMapPin,
TbPower,
TbTestPipe,
TbUser,
TbUserOff,
TbUsers,
TbUsersGroup,
TbWifi
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage,
})
// ── Mock Data ────────────────────────────────────────────────────────────────
// Mock data removed as it is replaced by API calls
// Remove chart data generators as they are replaced by API calls
// ── Helpers ───────────────────────────────────────────────────────────────────
// ── Activity Chart ────────────────────────────────────────────────────────────
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
const hasCustomRange = !!(dateFrom && dateTo)
const apiUrl = hasCustomRange
? API_URLS.graphLogVillages(villageId, period, dateFrom, dateTo)
: API_URLS.graphLogVillages(villageId, period)
const { data: response, isLoading } = useSWR(apiUrl, fetcher)
const labels: Record<ChartPeriod, string> = {
daily: 'Daily (last 14 days)',
monthly: 'Monthly (this year)',
yearly: 'Yearly',
}
const rawData: any[] = Array.isArray(response?.data) ? response.data : []
const data = rawData.map((item) => {
const label = item.label
const activity = item.aktivitas
return { label: String(label), activity: Number(activity) }
})
return (
<Paper withBorder radius="xl" p="lg">
<Group justify="space-between" mb="lg" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="blue">
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Village Activity Log</Text>
<Text size="xs" c="dimmed">
{hasCustomRange ? `${dateFrom}${dateTo}` : labels[period]}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="wrap">
<DatePickerInput
type="range"
placeholder="Pick date range"
size="xs"
radius="md"
value={dateRange}
onChange={setDateRange}
clearable
w={200}
/>
{!hasCustomRange && (
<SegmentedControl
value={period}
onChange={(v) => setPeriod(v as ChartPeriod)}
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
)}
</Group>
</Group>
{isLoading ? (
<Stack h={280} align="center" justify="center">
<Loader type="dots" />
</Stack>
) : (
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'activity', color: '#2563EB' }]}
curveType="monotone"
withTooltip
withDots
withPointLabels={false}
tickLine="none"
gridAxis="x"
fillOpacity={0.4}
tooltipAnimationDuration={150}
tooltipProps={{
content: ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
{label}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
</div>
</div>
)
},
}}
activeDotProps={{ r: 6, strokeWidth: 2 }}
styles={{
root: {
'.recharts-area-curve': {
strokeWidth: 3,
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))',
},
},
}}
/>
)}
</Paper>
)
}
// ── Peak Hours Chart ──────────────────────────────────────────────────────────
function PeakHoursChart({ villageId }: { villageId: string }) {
const { data: response, isLoading } = useSWR(API_URLS.getPeakHours(villageId), fetcher)
const hours: { hour: number; label: string; count: number }[] = response?.data?.hours || []
const peak: { label: string; count: number } | null = response?.data?.peak || null
return (
<Paper withBorder radius="xl" p="lg">
<Group justify="space-between" mb="lg" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="violet">
<TbClock size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Peak Activity Hours</Text>
<Text size="xs" c="dimmed">
{peak && peak.count > 0
? `Busiest hour: ${peak.label} (${peak.count.toLocaleString()} activities)`
: 'No activity data'}
</Text>
</Stack>
</Group>
</Group>
{isLoading ? (
<Stack h={200} align="center" justify="center">
<Loader type="dots" />
</Stack>
) : (
<BarChart
h={200}
data={hours}
dataKey="label"
series={[{ name: 'count', color: 'violet.5' }]}
withTooltip
withXAxis
withYAxis={false}
tickLine="none"
gridAxis="none"
barProps={{ radius: 4 }}
tooltipProps={{
content: ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
whiteSpace: 'nowrap',
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>{label}</div>
<div style={{ fontSize: '11px', color: '#9775FA' }}>
Activities: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
</div>
</div>
)
},
}}
/>
)}
</Paper>
)
}
// ── Recent Activity Logs ──────────────────────────────────────────────────────
function RecentVillageLogs({ villageId }: { villageId: string }) {
const { data: response, isLoading } = useSWR(API_URLS.getRecentVillageLogs(villageId), fetcher)
const logs: any[] = Array.isArray(response?.data) ? response.data : []
return (
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbClock size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Recent Activity</Text>
<Text size="xs" c="dimmed">Latest user actions in this village</Text>
</Stack>
</Group>
{isLoading ? (
<Stack h={120} align="center" justify="center">
<Loader type="dots" />
</Stack>
) : logs.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">No recent activity.</Text>
) : (
<Table.ScrollContainer minWidth={380}>
<Table verticalSpacing="xs" className="data-table">
<Table.Thead>
<Table.Tr>
<Table.Th style={{ whiteSpace: 'nowrap' }}>Time</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th style={{ whiteSpace: 'nowrap' }}>Action</Table.Th>
<Table.Th>Description</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log: any, i: number) => (
<Table.Tr key={i}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
<Text size="xs">{dayjs(log.timestamp).format('D MMM YYYY, HH:mm')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{log.userName || 'Unknown'}</Text>
</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
<Text size="xs">{log.action || '-'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{log.desc || '-'}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Paper>
)
}
// ── 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 (
<Paper withBorder radius="xl" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="red">
<TbUserOff size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Inactive Users</Text>
<Text size="xs" c="dimmed">
{isLoading ? 'Loading...' : `${total} users with no activity in the last ${days} days`}
</Text>
</Stack>
</Group>
<SegmentedControl
size="xs"
value={String(days)}
onChange={(v) => { setDays(Number(v) as 7 | 14 | 30); setPage(1) }}
data={[
{ label: '7D', value: '7' },
{ label: '14D', value: '14' },
{ label: '30D', value: '30' },
]}
/>
</Group>
{isLoading ? (
<Stack h={120} align="center" justify="center">
<Loader type="dots" />
</Stack>
) : users.length === 0 ? (
<Stack align="center" py="md" gap={4}>
<TbUsers size={28} style={{ opacity: 0.25 }} />
<Text size="sm" c="dimmed">No inactive users in this period.</Text>
</Stack>
) : (
<Stack gap="md">
<ScrollArea>
<Table verticalSpacing="xs" className="data-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Group / Position</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th style={{ whiteSpace: 'nowrap' }}>Last Activity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((u: any) => (
<Table.Tr key={u.id}>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{u.name}</Text>
<Text size="xs" c="dimmed">{u.email}</Text>
</Stack>
</Table.Td>
<Table.Td>
<Badge variant="light" color="brand-blue" size="sm" radius="sm">
{u.role}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs">{u.group}{u.position ? ` · ${u.position}` : ''}</Text>
</Table.Td>
<Table.Td>
<Badge variant="dot" color={u.isActive ? 'teal' : 'red'} size="sm">
{u.isActive ? 'Active' : 'Inactive'}
</Badge>
</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
{u.daysSince === null ? (
<Text size="xs" c="dimmed">Never</Text>
) : (
<Text size="xs" fw={600} c={u.daysSince > 30 ? 'red.5' : u.daysSince > 7 ? 'yellow.5' : 'dimmed'}>
{u.daysSince}d ago
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
{totalPages > 1 && (
<Group justify="center">
<Pagination value={page} onChange={setPage} total={totalPages} size="sm" radius="md" withEdges={false} siblings={1} />
</Group>
)}
</Stack>
)}
</Paper>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate()
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
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
const stats = gridRes?.data
const openEdit = () => {
setEditForm({
name: village?.name || '',
desc: village?.desc || '',
isDummy: village?.isDummy ?? false,
})
openEditModal()
}
const handleEditVillage = async () => {
if (!village) return
if (!editForm.name.trim() || !editForm.desc.trim()) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required.',
color: 'red'
})
return
}
setIsEditing(true)
try {
const res = await fetch(API_URLS.editVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
name: editForm.name,
desc: editForm.desc,
isDummy: editForm.isDummy,
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Village data updated (${appId}): ${editForm.name} - ${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'Village data has been updated successfully.',
color: 'teal'
})
mutate()
closeEditModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village data.',
color: 'red'
})
}
} catch {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsEditing(false)
}
}
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) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f9fafb'}">
<td style="width:80px;font-size:11px;font-weight:600">${d.label}</td>
<td>
<div style="background:#e5e7eb;border-radius:4px;height:10px;overflow:hidden">
<div style="width:${Math.round((d.aktivitas / maxActivity) * 100)}%;height:100%;background:#2563eb;border-radius:4px"></div>
</div>
</td>
<td style="text-align:right;width:60px;font-weight:700;font-size:11px">${d.aktivitas.toLocaleString()}</td>
</tr>`).join('')
const peakMax = Math.max(...peakHours.map(h => h.count), 1)
const peakRows = peakHours.filter(h => h.count > 0).map((h, i) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f9fafb'}">
<td style="font-weight:700;width:70px;font-size:11px">${h.label}</td>
<td>
<div style="background:#e5e7eb;border-radius:4px;height:8px;overflow:hidden">
<div style="width:${Math.round((h.count / peakMax) * 100)}%;height:100%;background:#7c3aed;border-radius:4px"></div>
</div>
</td>
<td style="text-align:right;width:70px;font-weight:600;font-size:11px">${h.count.toLocaleString()}</td>
</tr>`).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) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f9fafb'}">
<td style="white-space:nowrap;font-size:11px">${dayjs(log.timestamp).format('DD MMM YYYY HH:mm')}</td>
<td style="font-weight:600;font-size:11px">${log.userName}</td>
<td><span style="font-size:10px;font-weight:800;color:${actionColor(log.action)}">${log.action}</span></td>
<td style="font-size:11px;color:#6b7280">${log.desc || '-'}</td>
</tr>`).join('')
const inactiveRows = inactiveUsers.length === 0
? '<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:14px">No inactive users in this period.</td></tr>'
: inactiveUsers.map((u, i) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f9fafb'}">
<td>
<strong style="font-size:11px">${u.name}</strong><br>
<span style="font-size:10px;color:#9ca3af">${u.email}</span>
</td>
<td style="text-align:center;font-size:10px;font-weight:700">${u.role}</td>
<td style="font-size:10px">${u.group || '-'}${u.position ? ` · ${u.position}` : ''}</td>
<td style="text-align:center">
${u.daysSince === null
? '<span style="color:#9ca3af;font-size:10px">Never</span>'
: `<span style="font-weight:700;font-size:10px;color:${u.daysSince > 30 ? '#dc2626' : u.daysSince > 7 ? '#d97706' : '#059669'}">${u.daysSince}d ago</span>`}
</td>
</tr>`).join('')
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>${village.name} — Village Report</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; color: #111; background: #fff; font-size: 12px; }
.cover { background: linear-gradient(135deg, #1d4ed8, #7c3aed); color: white; padding: 36px 40px 28px; }
.cover h1 { font-size: 24px; font-weight: 800; margin-bottom: 6px; }
.cover p { font-size: 12px; opacity: 0.85; margin-top: 4px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 2px solid #e5e7eb; }
.summary-card { padding: 14px 16px; border-right: 1px solid #e5e7eb; }
.summary-card:last-child { border-right: none; }
.summary-card .label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 4px; }
.summary-card .value { font-size: 26px; font-weight: 800; }
.summary-card .sub { font-size: 10px; color: #9ca3af; margin-top: 2px; }
.section { padding: 18px 32px; border-bottom: 1px solid #f3f4f6; }
.section h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; padding-bottom: 8px; border-bottom: 2px solid #e5e7eb; margin-bottom: 14px; }
table { width: 100%; border-collapse: collapse; }
th { font-size: 9px; font-weight: 700; text-transform: uppercase; color: #6b7280; padding: 7px 10px; border-bottom: 2px solid #e5e7eb; text-align: left; background: #f9fafb; }
td { padding: 7px 10px; border-bottom: 1px solid #f3f4f6; vertical-align: middle; line-height: 1.4; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.footer { padding: 14px 32px; border-top: 2px solid #e5e7eb; font-size: 10px; color: #9ca3af; text-align: center; }
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .section { page-break-inside: avoid; } }
</style>
</head>
<body>
<div class="cover">
<h1>${village.name}</h1>
<p>Village Head (Perbekel): <strong>${village.perbekel || '-'}</strong></p>
<p>Status: <strong style="color:${village.isActive ? '#6ee7b7' : '#fca5a5'}">${village.isActive ? 'Active' : 'Inactive'}</strong>${village.isDummy ? ' &nbsp;·&nbsp; <span style="color:#fde68a">Dummy Data</span>' : ''}</p>
<p>Created: ${village.createdAt} &nbsp;·&nbsp; Last Updated: ${village.updatedAt || '-'}</p>
<p style="margin-top:10px;opacity:0.65">Generated: ${generatedAt}</p>
</div>
<div class="summary">
<div class="summary-card">
<div class="label">Active Users</div>
<div class="value" style="color:#2563eb">${stats.user.active.toLocaleString()}</div>
<div class="sub">${stats.user.nonActive} inactive</div>
</div>
<div class="summary-card">
<div class="label">Groups</div>
<div class="value" style="color:#7c3aed">${stats.group.active.toLocaleString()}</div>
<div class="sub">${stats.group.nonActive} inactive</div>
</div>
<div class="summary-card">
<div class="label">Divisions</div>
<div class="value" style="color:#0891b2">${stats.division.active.toLocaleString()}</div>
<div class="sub">${stats.division.nonActive} inactive</div>
</div>
<div class="summary-card">
<div class="label">Projects</div>
<div class="value" style="color:#d97706">${stats.project.active.toLocaleString()}</div>
<div class="sub">${stats.project.nonActive} inactive</div>
</div>
</div>
<div class="section">
<div class="two-col">
<div>
<h2>Activity Trend — Last 14 Days</h2>
${activityData.length === 0
? '<p style="color:#9ca3af;font-size:11px;padding:8px 0">No activity data available.</p>'
: `<table>
<thead><tr><th>Date</th><th>Distribution</th><th style="text-align:right">Count</th></tr></thead>
<tbody>${activityRows}</tbody>
</table>`}
</div>
<div>
<h2>Peak Activity Hours</h2>
${peak && peak.count > 0
? `<p style="font-size:11px;color:#6b7280;margin-bottom:10px">Busiest hour: <strong>${peak.label}</strong> (${peak.count.toLocaleString()} activities)</p>`
: '<p style="font-size:11px;color:#9ca3af;margin-bottom:10px">No peak data available.</p>'}
<table>
<thead><tr><th>Hour</th><th>Distribution</th><th style="text-align:right">Count</th></tr></thead>
<tbody>${peakRows || '<tr><td colspan="3" style="text-align:center;color:#9ca3af;padding:12px">No data</td></tr>'}</tbody>
</table>
</div>
</div>
</div>
<div class="section">
<h2>Recent Activity — Last 10 Logs</h2>
${recentLogs.length === 0
? '<p style="color:#9ca3af;font-size:11px">No recent activity recorded.</p>'
: `<table>
<thead>
<tr>
<th style="width:18%">Time</th>
<th style="width:22%">User</th>
<th style="width:10%">Action</th>
<th>Description</th>
</tr>
</thead>
<tbody>${logRows}</tbody>
</table>`}
</div>
<div class="section">
<h2>Inactive Users — No Activity in Last 7 Days (${totalInactive}${totalInactive > inactiveUsers.length ? `, showing first ${inactiveUsers.length}` : ''})</h2>
<table>
<thead>
<tr>
<th style="width:32%">Name / Email</th>
<th style="text-align:center;width:15%">Role</th>
<th style="width:30%">Group / Position</th>
<th style="text-align:center;width:13%">Last Activity</th>
</tr>
</thead>
<tbody>${inactiveRows}</tbody>
</table>
</div>
<div class="footer">
${village.name} &nbsp;·&nbsp; ${generatedAt} &nbsp;·&nbsp; Desa+ Monitoring System
</div>
<script>window.onload = () => window.print()<\/script>
</body>
</html>`
const win = window.open('', '_blank')
if (win) { win.document.write(html); win.document.close() }
} finally {
setIsExporting(false)
}
}
const handleConfirmToggle = async () => {
if (!village) return
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateStatusVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
active: !village.isActive
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Village status updated (${appId}): ${village.name} ${!village.isActive ? 'activated' : 'deactivated'} - ${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
color: 'teal'
})
mutate()
closeConfirmModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village status.',
color: 'red'
})
}
} catch {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsUpdating(false)
}
}
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) {
return (
<Group justify="center" py="xl">
<Loader type="dots" />
</Group>
)
}
if (!village) {
return (
<Stack align="center" py="xl" gap="md">
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
<Title order={4}>Village not found</Title>
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Back to List
</Button>
</Stack>
)
}
return (
<Stack gap="xl">
{/* ── Back Button ── */}
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<TbArrowLeft size={16} />}
radius="md"
onClick={goBack}
>
Village List
</Button>
{/* Action Buttons */}
<Group gap="sm">
<Button
variant="light"
color="gray"
size="sm"
radius="md"
leftSection={<TbFileText size={16} />}
onClick={handleDownloadPDF}
loading={isExporting}
disabled={!village || !stats}
>
Download PDF
</Button>
<Button
variant="filled"
color={village.isActive ? 'red' : 'green'}
leftSection={<TbPower size={16} />}
onClick={openConfirmModal}
radius="md"
loading={isUpdating}
disabled={!isDeveloper}
>
{village.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Button
variant="light"
color="blue"
leftSection={<TbEdit size={16} />}
onClick={openEdit}
radius="md"
>
Edit
</Button>
</Group>
</Group>
{/* ── Header Banner ── */}
<Paper
radius="xl"
p="xl"
style={{
background: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 60%, #7c3aed 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Decorative blobs */}
<Box style={{ position: 'absolute', top: -50, right: -50, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
<Box style={{ position: 'absolute', bottom: -70, right: 100, width: 160, height: 160, borderRadius: '50%', background: 'rgba(255,255,255,0.04)' }} />
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="lg">
<ThemeIcon
size={68}
radius="xl"
style={{ background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.2)' }}
>
<TbHome2 size={32} color="white" />
</ThemeIcon>
<Stack gap={6}>
<Group gap="xs" align="center">
<Title order={2} style={{ color: 'white', lineHeight: 1.1 }}>{village.name}</Title>
{village.isDummy && (
<Badge
size="sm"
variant="light"
color="yellow"
leftSection={<TbTestPipe size={11} />}
style={{ textTransform: 'none' }}
>
Dummy
</Badge>
)}
</Group>
<Group gap={6}>
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Location data not available
</Text>
</Group>
<Group gap={6}>
<TbUser size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Village Head: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
</Text>
</Group>
{/* <Group gap="xs" mt={2}>
<Badge
variant="outline"
radius="sm"
size="sm"
style={{ color: 'white', borderColor: 'rgba(255,255,255,0.45)' }}
leftSection={<TbCircleCheck size={11} />}
>
{cfg.label}
</Badge>
</Group> */}
</Stack>
</Group>
{/* Last Sync block */}
<Stack gap={4} align="flex-end">
{/* <Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text> */}
<Group gap={6}>
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
</Group>
</Stack>
</Group>
</Paper>
{/* ── Stats Cards ── */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ 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) => (
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
<Group justify="space-between" align="flex-start" mb="xs">
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
<s.icon size={18} />
</ThemeIcon>
<Stack gap={0} align="flex-end">
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
</Stack>
</Group>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{s.label}
</Text>
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
</Card>
))}
</SimpleGrid>
{/* ── Activity Chart ── */}
<ActivityChart villageId={villageId} />
{/* ── Peak Hours Chart ── */}
<PeakHoursChart villageId={villageId} />
{/* ── Recent Logs + System Info ── */}
<Grid gutter="md" align="flex-start">
<Grid.Col span={{ base: 12, md: 8 }}>
<RecentVillageLogs villageId={villageId} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} />
</ThemeIcon>
<Text fw={700} size="sm">System Information</Text>
</Group>
<Stack gap={0}>
{[
{ label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' },
{ label: 'Last Updated', value: village.updatedAt },
].map((item, idx, arr) => (
<Group
key={item.label}
justify="space-between"
py="xs"
wrap="wrap"
style={{
borderBottom: idx < arr.length - 1 ? '1px solid var(--mantine-color-default-border)' : 'none',
}}
>
<Text size="xs" c="dimmed">{item.label}</Text>
<Text size="xs" fw={600} ta="right">{item.value}</Text>
</Group>
))}
</Stack>
</Paper>
</Grid.Col>
</Grid>
{/* ── Inactive Users ── */}
<InactiveVillageUsers villageId={villageId} />
{/* ── Confirmation Modal ── */}
<Modal
opened={confirmModalOpened}
onClose={closeConfirmModal}
radius="md"
title={<Text fw={700} size="lg">Confirm Status Change</Text>}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
Cancel
</Button>
<Button
color={village.isActive ? 'red' : 'green'}
onClick={handleConfirmToggle}
loading={isUpdating}
radius="md"
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
{/* ── Edit Village Modal ── */}
<Modal
opened={editModalOpened}
onClose={closeEditModal}
title={<Text fw={700}>Edit Village Details</Text>}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Village Name"
placeholder="Enter village name"
required
value={editForm.name}
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Enter village description..."
minRows={3}
required
value={editForm.desc}
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
<Switch
label="Dummy Village"
description="Tandai desa ini sebagai data dummy"
checked={editForm.isDummy}
onChange={(e) => setEditForm(prev => ({ ...prev, isDummy: e.currentTarget.checked }))}
/>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
Cancel
</Button>
<Button
variant="filled"
color="blue"
onClick={handleEditVillage}
loading={isEditing}
radius="md"
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}