amalia/28-mei-26 #27

Merged
amaliadwiy merged 16 commits from amalia/28-mei-26 into main 2026-05-28 17:22:56 +08:00
13 changed files with 1205 additions and 210 deletions
Showing only changes of commit ed9c1da878 - Show all commits

View File

@@ -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) => `
<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
@@ -612,10 +824,22 @@ function VillageDetailPage() {
{/* 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={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
leftSection={<TbPower size={16} />}
onClick={openConfirmModal}
radius="md"
loading={isUpdating}