- 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
1093 lines
40 KiB
TypeScript
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 ? ' · <span style="color:#fde68a">Dummy Data</span>' : ''}</p>
|
|
<p>Created: ${village.createdAt} · 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} · ${generatedAt} · 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>
|
|
)
|
|
}
|