all features

This commit is contained in:
panggilsajarey 2026-02-24 23:27:49 +07:00
parent a6d4670f06
commit f6e65b5545
24 changed files with 4386 additions and 6 deletions

View File

@ -73,3 +73,73 @@ export async function logout() {
cookieStore.delete('user_session')
redirect('/')
}
export async function updatePetugas(prevState: any, formData: FormData) {
const id = formData.get('id') as string
const nama = formData.get('nama') as string
const username = formData.get('username') as string
const no_telp = formData.get('no_telp') as string
const password = formData.get('password') as string
if (!id || !nama || !username || !password) {
return { success: false, message: 'Semua field wajib diisi.' }
}
try {
const { error } = await supabase
.from('petugas_posyandu')
.update({
nama,
username,
no_telp,
password
})
.eq('id', id)
if (error) throw error
return { success: true, message: 'Profil berhasil diperbarui!' }
} catch (error) {
console.error('Error updating profile:', error)
return { success: false, message: 'Gagal memperbarui profil. Coba lagi.' }
}
}
export async function updateAkunBalita(prevState: any, formData: FormData) {
const id = formData.get('id') as string
const nama_orang_tua = formData.get('nama_orang_tua') as string
const alamat = formData.get('alamat') as string
const no_whatsapp = formData.get('no_whatsapp') as string
const nama_anak = formData.get('nama_anak') as string
const tanggal_lahir = formData.get('tanggal_lahir') as string
const username = formData.get('username') as string
const password = formData.get('password') as string
if (!id || !nama_orang_tua || !nama_anak || !username || !password) {
return { success: false, message: 'Field wajib tidak boleh kosong.' }
}
try {
const { error } = await supabase
.from('akun_balita')
.update({
nama_orang_tua,
alamat,
no_whatsapp,
nama_anak,
tanggal_lahir: tanggal_lahir || null,
username,
password,
})
.eq('id', id)
if (error) throw error
return { success: true, message: 'Data pengguna berhasil diperbarui!' }
} catch (error) {
console.error('Error updating akun balita:', error)
return { success: false, message: 'Gagal memperbarui data pengguna. Coba lagi.' }
}
}

View File

@ -0,0 +1,544 @@
'use client'
import { useState, useRef, useMemo, useEffect } from 'react'
import { FileDown, FolderOpen, X, Loader2, CheckCircle2, ChevronDown } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import {
AreaChart, Area,
XAxis, YAxis, CartesianGrid,
ResponsiveContainer,
} from 'recharts'
// ─── TYPES ────────────────────────────────────────────────────────
interface HasilItem {
id: number
id_balita: number
tinggi_badan: number | null
berat_badan: number | null
status_stunting: boolean | null
pesan_ai: string | null
tanggal_upload: string | null
nama_posyandu: string | null
}
interface PenggunaData {
id: number
nama_orang_tua: string
alamat: string | null
nama_anak: string
jenis_kelamin: string | null
tanggal_lahir: string | null
}
const MONTHS = [
{ num: 1, name: 'Januari' }, { num: 2, name: 'Februari' },
{ num: 3, name: 'Maret' }, { num: 4, name: 'April' },
{ num: 5, name: 'Mei' }, { num: 6, name: 'Juni' },
{ num: 7, name: 'Juli' }, { num: 8, name: 'Agustus' },
{ num: 9, name: 'September' }, { num: 10, name: 'Oktober' },
{ num: 11, name: 'November' }, { num: 12, name: 'Desember' },
]
const START_YEAR = 2026
const currentYear = new Date().getFullYear()
const YEARS = Array.from(
{ length: Math.max(currentYear, START_YEAR) - START_YEAR + 1 },
(_, i) => START_YEAR + i,
)
type Step = 'config' | 'generating' | 'done' | 'error'
// ─── HELPERS ──────────────────────────────────────────────────────
function formatTgl(d: string | null, style: 'long' | 'short' = 'long') {
if (!d) return '-'
return new Date(d).toLocaleDateString('id-ID', {
day: 'numeric',
month: style === 'long' ? 'long' : 'short',
year: 'numeric',
})
}
function build5MonthData(allData: HasilItem[], rowDate: Date) {
const slots = []
for (let i = 4; i >= 0; i--) {
const d = new Date(rowDate.getFullYear(), rowDate.getMonth() - i, 1)
slots.push({ year: d.getFullYear(), month: d.getMonth() + 1 })
}
return slots.map(slot => {
const match = allData.find(item => {
if (!item.tanggal_upload) return false
const id = new Date(item.tanggal_upload)
return id.getFullYear() === slot.year && id.getMonth() + 1 === slot.month && id <= rowDate
})
const label = new Date(slot.year, slot.month - 1, 1).toLocaleDateString('id-ID', { month: 'short', year: '2-digit' })
return { label, tinggi: match?.tinggi_badan ?? null, berat: match?.berat_badan ?? null }
})
}
export function CetakInstanModal() {
const [open, setOpen] = useState(false)
const [year, setYear] = useState(START_YEAR)
const [month, setMonth] = useState(new Date().getMonth() + 1)
const [dirHandle, setDirHandle] = useState<FileSystemDirectoryHandle | null>(null)
const [step, setStep] = useState<Step>('config')
const [progress, setProgress] = useState({ current: 0, total: 0, name: '', mama: '' })
const [elapsed, setElapsed] = useState(0)
const [errorMsg, setErrorMsg] = useState('')
// Template state for batch processing
const [activePrintData, setActivePrintData] = useState<{
pengguna: PenggunaData
row: HasilItem
allHasil: HasilItem[]
} | null>(null)
const templateRef = useRef<HTMLDivElement>(null)
const monthName = MONTHS.find(m => m.num === month)?.name ?? 'Bulan'
const folderName = `${monthName.toLowerCase()}_${year}`
const supportsFileSys = typeof window !== 'undefined' && 'showDirectoryPicker' in window
const pickDir = async () => {
try {
const handle = await (window as any).showDirectoryPicker({ mode: 'readwrite' })
setDirHandle(handle)
} catch { /* user cancelled */ }
}
const reset = () => {
setStep('config')
setProgress({ current: 0, total: 0, name: '', mama: '' })
setElapsed(0)
setErrorMsg('')
setActivePrintData(null)
}
const handleClose = () => {
setOpen(false)
setTimeout(reset, 300)
}
const handleGenerate = async () => {
setStep('generating')
const startTime = Date.now()
const timer = setInterval(() => setElapsed(Math.floor((Date.now() - startTime) / 1000)), 500)
try {
const { default: html2canvas } = await import('html2canvas')
const { default: jsPDF } = await import('jspdf')
// 1. Fetch data
const { data: balitaList, error: eB } = await supabase
.from('akun_balita')
.select('*')
.order('nama_orang_tua', { ascending: true })
if (eB) throw new Error(eB.message)
const fromDate = new Date(year - 1, month - 2, 1).toISOString().split('T')[0]
const toDate = new Date(year, month - 1, 31).toISOString().split('T')[0]
const { data: hasilAll, error: eH } = await supabase
.from('hasil_stunting_balita')
.select('*')
.gte('tanggal_upload', fromDate)
.lte('tanggal_upload', toDate)
.order('tanggal_upload', { ascending: true })
if (eH) throw new Error(eH.message)
const targets = (balitaList ?? []).filter(b =>
(hasilAll ?? []).some(h => {
if (h.id_balita !== b.id || !h.tanggal_upload) return false
const d = new Date(h.tanggal_upload)
return d.getFullYear() === year && d.getMonth() + 1 === month
})
)
if (targets.length === 0) {
setErrorMsg(`Tidak ada data balita untuk ${monthName} ${year}`)
setStep('error')
clearInterval(timer)
return
}
setProgress({ current: 0, total: targets.length, name: '', mama: '' })
// 2. Prepare folder
let folderHandle: FileSystemDirectoryHandle | null = null
if (dirHandle) {
folderHandle = await dirHandle.getDirectoryHandle(folderName, { create: true })
}
// 3. Generation Loop
for (let i = 0; i < targets.length; i++) {
const b = targets[i] as PenggunaData
const balitaHasil = (hasilAll ?? []).filter(h => h.id_balita === b.id) as HasilItem[]
const rowForMonth = balitaHasil.find(h => {
if (!h.tanggal_upload) return false
const d = new Date(h.tanggal_upload)
return d.getFullYear() === year && d.getMonth() + 1 === month
})
if (!rowForMonth) continue
setProgress({ current: i + 1, total: targets.length, name: b.nama_anak, mama: b.nama_orang_tua })
// --- Update template and wait for render ---
setActivePrintData({ pengguna: b, row: rowForMonth, allHasil: balitaHasil })
// Give React and Recharts some time to finish rendering the hidden template
await new Promise(r => setTimeout(r, 600)) // 600ms buffer for Recharts animations/stable DOM
if (!templateRef.current) continue
// --- Capture ---
const canvas = await html2canvas(templateRef.current, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
})
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF('p', 'mm', 'a4')
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()
const imgH = (canvas.height * pageW) / canvas.width
if (imgH <= pageH) {
pdf.addImage(imgData, 'PNG', 0, 0, pageW, imgH)
} else {
let yPos = 0
const sliceH = canvas.width * (pageH / pageW)
while (yPos < canvas.height) {
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = Math.min(sliceH, canvas.height - yPos)
const ctx = sliceCanvas.getContext('2d')!
ctx.drawImage(canvas, 0, -yPos)
if (yPos > 0) pdf.addPage()
pdf.addImage(sliceCanvas.toDataURL('image/png'), 'PNG', 0, 0, pageW, pageH)
yPos += sliceH
}
}
// --- Save ---
const fileName = `Laporan_${b.nama_anak.replace(/\s+/g, '_')}.pdf`
const blob = pdf.output('blob')
if (folderHandle) {
const fh = await folderHandle.getFileHandle(fileName, { create: true })
const writable = await fh.createWritable()
await writable.write(blob)
await writable.close()
} else {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
await new Promise(r => setTimeout(r, 200))
}
}
clearInterval(timer)
setStep('done')
} catch (err: any) {
clearInterval(timer)
setErrorMsg(err?.message ?? 'Terjadi kesalahan saat mencetak.')
setStep('error')
} finally {
setActivePrintData(null)
}
}
const pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
const estRemaining = progress.current > 0
? Math.round((elapsed / progress.current) * (progress.total - progress.current))
: null
// ── Template helper variables ──────────────────────────────────
const chartData = useMemo(() => {
if (!activePrintData) return []
const rowDate = activePrintData.row.tanggal_upload ? new Date(activePrintData.row.tanggal_upload) : new Date()
return build5MonthData(activePrintData.allHasil, rowDate)
}, [activePrintData])
const isStunting = activePrintData?.row.status_stunting === true
const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
return (
<>
{/* Trigger button */}
<button
onClick={() => { setOpen(true); reset() }}
className="flex items-center gap-2 px-4 py-2.5 bg-black text-white text-sm font-bold rounded-xl hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.25)] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.2)] hover:translate-x-[1px] hover:translate-y-[1px]"
>
<FileDown className="w-4 h-4" />
Cetak Data Instan
</button>
{/* Backdrop + Modal */}
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={step === 'config' ? handleClose : undefined}
>
<div
className="bg-white rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] w-full max-w-md overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b-2 border-black bg-black text-white">
<div className="flex items-center gap-3">
<FileDown className="w-5 h-5" />
<div>
<p className="font-black text-lg leading-none">Cetak Data Instan</p>
<p className="text-[10px] text-gray-400 mt-0.5 uppercase tracking-widest">
Generate PDF semua balita per periode
</p>
</div>
</div>
{step !== 'generating' && (
<button onClick={handleClose} className="p-1.5 rounded-full hover:bg-white/10 transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
{/* ── Steps: Config, Generating, Done, Error ── */}
{step === 'config' && (
<div className="p-6 flex flex-col gap-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-3">Pilih Periode</p>
<div className="grid grid-cols-2 gap-3">
<div className="relative">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1 block">Tahun</label>
<select value={year} onChange={e => setYear(Number(e.target.value))} className="w-full appearance-none border-2 border-gray-200 focus:border-black rounded-lg pl-3 pr-8 py-2.5 text-sm font-bold bg-white focus:outline-none transition-colors cursor-pointer">
{YEARS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<ChevronDown className="absolute right-3 bottom-2.5 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
<div className="relative">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1 block">Bulan</label>
<select value={month} onChange={e => setMonth(Number(e.target.value))} className="w-full appearance-none border-2 border-gray-200 focus:border-black rounded-lg pl-3 pr-8 py-2.5 text-sm font-bold bg-white focus:outline-none transition-colors cursor-pointer">
{MONTHS.map(m => <option key={m.num} value={m.num}>{m.name}</option>)}
</select>
<ChevronDown className="absolute right-3 bottom-2.5 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-3">Lokasi Penyimpanan</p>
{supportsFileSys ? (
<div className="flex flex-col gap-2">
<button onClick={pickDir} className="flex items-center gap-2 px-4 py-2.5 border-2 border-dashed border-gray-300 hover:border-black rounded-xl text-sm font-semibold text-gray-600 hover:text-black transition-all">
<FolderOpen className="w-4 h-4" />
{dirHandle ? dirHandle.name : 'Pilih folder tujuan...'}
</button>
{dirHandle && <div className="flex items-center gap-2 text-xs text-emerald-600 font-semibold"><CheckCircle2 className="w-3.5 h-3.5" />Folder dibuat: <span className="font-black">{folderName}</span></div>}
</div>
) : (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700">Browser tidak mendukung pemilihan direktori. PDF akan diunduh satu per satu.</div>
)}
</div>
<button onClick={handleGenerate} className="w-full py-3 bg-black text-white font-black text-sm rounded-xl hover:bg-gray-800 transition-all flex items-center justify-center gap-2">
<FileDown className="w-4 h-4" />
Mulai Cetak PDF
</button>
</div>
)}
{step === 'generating' && (
<div className="p-6 flex flex-col gap-5">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-black animate-spin flex-shrink-0" />
<div>
<p className="font-bold text-base">Sedang mencetak PDF...</p>
<p className="text-xs text-gray-400">Harap tunggu, jangan tutup halaman ini.</p>
</div>
</div>
<div>
<div className="flex justify-between text-xs font-bold mb-1.5">
<span>{progress.current} / {progress.total} balita</span>
<span>{pct}%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden border border-gray-200">
<div className="h-full bg-black rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
{progress.name && (
<div className="px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl animate-pulse">
<p className="font-black text-sm">{progress.name}</p>
<p className="text-xs text-gray-500">Ibu: {progress.mama}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="text-center px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl">
<p className="text-2xl font-black font-mono">{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}</p>
</div>
<div className="text-center px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl">
<p className="text-2xl font-black font-mono">{estRemaining !== null ? `${String(Math.floor(estRemaining / 60)).padStart(2, '0')}:${String(estRemaining % 60).padStart(2, '0')}` : '--:--'}</p>
</div>
</div>
</div>
)}
{step === 'done' && (
<div className="p-6 flex flex-col gap-5 items-center text-center">
<CheckCircle2 className="w-12 h-12 text-emerald-500" />
<p className="font-black text-xl">Selesai! {progress.total} file dicetak.</p>
<button onClick={handleClose} className="px-8 py-2.5 bg-black text-white font-bold rounded-xl hover:bg-gray-800 transition-colors">Tutup</button>
</div>
)}
{step === 'error' && (
<div className="p-6 flex flex-col gap-5 items-center text-center">
<p className="font-black text-xl text-red-700">Gagal</p>
<p className="text-sm text-gray-500">{errorMsg}</p>
<button onClick={reset} className="px-8 py-2.5 bg-black text-white font-bold rounded-xl">Coba Lagi</button>
</div>
)}
</div>
</div>
)}
{/* ─── HIDDEN PDF TEMPLATE (Rich HTML) ─── */}
{activePrintData && (
<div style={{ position: 'fixed', top: '-9999px', left: '-9999px', zIndex: -1 }}>
<div
ref={templateRef}
style={{
width: 794,
backgroundColor: '#ffffff',
fontFamily: 'Arial, Helvetica, sans-serif',
color: '#111111',
padding: '48px 56px',
boxSizing: 'border-box',
}}
>
{/* Header */}
<div style={{ borderBottom: '3px solid #111', paddingBottom: 20, marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>Sistem Informasi Posyandu</div>
<div style={{ fontSize: 22, fontWeight: 900, letterSpacing: -0.5 }}>Laporan Pemeriksaan Balita</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#888' }}>Tanggal Cetak</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalCetak}</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Tgl Pemeriksaan</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{formatTgl(activePrintData.row.tanggal_upload)}</div>
</div>
</div>
{/* Identitas */}
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Identitas</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px' }}>
{[
['Nama Ibu / Orang Tua', activePrintData.pengguna.nama_orang_tua],
['Nama Anak', activePrintData.pengguna.nama_anak],
['Alamat', activePrintData.pengguna.alamat ?? '-'],
['Jenis Kelamin', activePrintData.pengguna.jenis_kelamin ?? '-'],
['Tanggal Lahir', formatTgl(activePrintData.pengguna.tanggal_lahir)],
].map(([label, value], i) => (
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 8 }}>
<div style={{ fontSize: 9, color: '#888', fontWeight: 700, textTransform: 'uppercase' }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
</div>
))}
</div>
</div>
{/* Charts */}
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Grafik Perkembangan (5 Bulan)</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 12, padding: '14px 14px 4px', background: '#eff6ff' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#1d4ed8', marginBottom: 8 }}>📏 Tinggi Badan (cm)</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="instanTinggi" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
<XAxis dataKey="label" fontSize={9} axisLine={false} tickLine={false} />
<YAxis fontSize={9} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#instanTinggi)" dot={{ r: 4, fill: '#3b82f6', stroke: 'white' }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 12, padding: '14px 14px 4px', background: '#f0fdf4' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#059669', marginBottom: 8 }}> Berat Badan (kg)</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="instanBerat" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
<XAxis dataKey="label" fontSize={9} axisLine={false} tickLine={false} />
<YAxis fontSize={9} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#instanBerat)" dot={{ r: 4, fill: '#10b981', stroke: 'white' }} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Table */}
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Data Pemeriksaan</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#111', color: '#fff' }}>
{['Tinggi', 'Berat', 'Status', 'Posyandu', 'Tgl Upload'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontSize: 10 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
<tr style={{ background: '#f9fafb' }}>
<td style={{ padding: '10px 12px', fontWeight: 700 }}>{activePrintData.row.tinggi_badan} cm</td>
<td style={{ padding: '10px 12px', fontWeight: 700 }}>{activePrintData.row.berat_badan} kg</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 700, background: isStunting ? '#fee2e2' : '#dcfce7', color: isStunting ? '#991b1b' : '#166534' }}>
{isStunting ? 'Stunting' : 'Normal'}
</span>
</td>
<td style={{ padding: '10px 12px' }}>{activePrintData.row.nama_posyandu}</td>
<td style={{ padding: '10px 12px' }}>{formatTgl(activePrintData.row.tanggal_upload)}</td>
</tr>
</tbody>
</table>
{activePrintData.row.pesan_ai && (
<div style={{ marginTop: 15, border: '1px solid #fde68a', borderRadius: 8, padding: '12px', background: '#fffbeb' }}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#92400e', marginBottom: 4 }}>PESAN AI</div>
<div style={{ fontSize: 11, color: '#78350f' }}>{activePrintData.row.pesan_ai}</div>
</div>
)}
</div>
{/* Signatures */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, marginTop: 40, paddingTop: 20, borderTop: '1px solid #eee' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 40 }}>Mengetahui,</div>
<div style={{ borderTop: '1px solid #333', fontSize: 10, paddingTop: 4 }}>Supervisor</div>
</div>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 40 }}>Petugas Posyandu,</div>
<div style={{ borderTop: '1px solid #333', fontSize: 10, paddingTop: 4 }}>Nama & Tanda Tangan</div>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { Eye, Baby, Search } from 'lucide-react'
import Link from 'next/link'
interface AkunBalita {
id: number
nama_orang_tua: string
nama_anak: string
}
interface Props {
data: AkunBalita[]
}
export function KelolaDataTable({ data }: Props) {
const [search, setSearch] = useState('')
const filtered = data.filter(d =>
d.nama_orang_tua?.toLowerCase().includes(search.toLowerCase()) ||
d.nama_anak?.toLowerCase().includes(search.toLowerCase())
)
return (
<>
{/* Search */}
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama ibu atau nama anak..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border-2 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-black transition-colors"
/>
</div>
<span className="text-xs text-gray-400 font-semibold">
{filtered.length} dari {data.length} data
</span>
</div>
{/* Table */}
<div className="rounded-xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[60px_1fr_1fr_120px] bg-black text-white px-6 py-4 text-xs font-bold uppercase tracking-widest">
<span className="text-center text-gray-400">#</span>
<span>Nama Ibu / Orang Tua</span>
<span>Nama Anak</span>
<span className="text-center">Aksi</span>
</div>
{/* Rows */}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-300">
<Baby className="w-12 h-12 opacity-30" />
<p className="font-semibold text-gray-400">Tidak ada data ditemukan</p>
</div>
) : (
filtered.map((row, idx) => (
<div
key={row.id}
className={`grid grid-cols-[60px_1fr_1fr_120px] items-center px-6 py-4 border-b border-gray-100 hover:bg-orange-50/50 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/40'}`}
>
{/* Index */}
<div className="flex justify-center">
<span className="w-7 h-7 rounded-full bg-gray-100 border border-gray-200 flex items-center justify-center text-xs font-bold text-gray-500">
{idx + 1}
</span>
</div>
{/* Nama Ibu */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-orange-100 border border-orange-200 flex items-center justify-center text-orange-700 text-xs font-bold flex-shrink-0">
{row.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'}
</div>
<span className="text-sm font-semibold truncate">{row.nama_orang_tua ?? '-'}</span>
</div>
{/* Nama Anak */}
<div className="flex items-center gap-2">
<Baby className="w-3.5 h-3.5 text-gray-300 flex-shrink-0" />
<span className="text-sm text-gray-700 truncate">{row.nama_anak ?? '-'}</span>
</div>
{/* Aksi — navigate ke halaman detail */}
<div className="flex justify-center">
<Link
href={`/dashboard/kelola-data/${row.id}`}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-500 text-white text-xs font-bold rounded-lg hover:bg-orange-600 transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px]"
>
<Eye className="w-3.5 h-3.5" />
Review
</Link>
</div>
</div>
))
)}
</div>
</>
)
}

View File

@ -0,0 +1,319 @@
'use client'
import { useRef, useMemo, useState } from 'react'
import { Printer } from 'lucide-react'
import {
AreaChart, Area,
XAxis, YAxis, CartesianGrid,
ResponsiveContainer,
} from 'recharts'
interface HasilItem {
id: number
tinggi_badan: number | null
berat_badan: number | null
status_stunting: boolean | null
pesan_ai: string | null
tanggal_upload: string | null
nama_posyandu: string | null
}
interface Pengguna {
nama_orang_tua: string
alamat: string | null
nama_anak: string
jenis_kelamin: string | null
tanggal_lahir: string | null
}
interface Props {
row: HasilItem
allData: HasilItem[]
pengguna: Pengguna
}
function formatTgl(d: string | null, style: 'long' | 'short' = 'long') {
if (!d) return '-'
return new Date(d).toLocaleDateString('id-ID', {
day: 'numeric',
month: style === 'long' ? 'long' : 'short',
year: 'numeric',
})
}
/** Build 5-month window ending at rowDate (inclusive), no future data */
function build5MonthData(allData: HasilItem[], rowDate: Date) {
const slots = []
for (let i = 4; i >= 0; i--) {
const d = new Date(rowDate.getFullYear(), rowDate.getMonth() - i, 1)
slots.push({ year: d.getFullYear(), month: d.getMonth() + 1 })
}
return slots.map(slot => {
const match = allData.find(item => {
if (!item.tanggal_upload) return false
const id = new Date(item.tanggal_upload)
return (
id.getFullYear() === slot.year &&
id.getMonth() + 1 === slot.month &&
id <= rowDate // no future data
)
})
const label = new Date(slot.year, slot.month - 1, 1)
.toLocaleDateString('id-ID', { month: 'short', year: '2-digit' })
return {
label,
tinggi: match?.tinggi_badan ?? null,
berat: match?.berat_badan ?? null,
}
})
}
export function CetakPDFButton({ row, allData, pengguna }: Props) {
const templateRef = useRef<HTMLDivElement>(null)
const [loading, setLoading] = useState(false)
const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date()
const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload])
const isStunting = row.status_stunting === true
const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
const tanggalUpload = formatTgl(row.tanggal_upload, 'long')
const tanggalLahir = formatTgl(pengguna.tanggal_lahir, 'long')
const handlePrint = async () => {
if (!templateRef.current || loading) return
setLoading(true)
try {
const { default: html2canvas } = await import('html2canvas')
const { default: jsPDF } = await import('jspdf')
const canvas = await html2canvas(templateRef.current, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
})
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF('p', 'mm', 'a4')
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()
const imgH = (canvas.height * pageW) / canvas.width
if (imgH <= pageH) {
pdf.addImage(imgData, 'PNG', 0, 0, pageW, imgH)
} else {
// Multi-page
let yPos = 0
const sliceH = canvas.width * (pageH / pageW)
while (yPos < canvas.height) {
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = Math.min(sliceH, canvas.height - yPos)
const ctx = sliceCanvas.getContext('2d')!
ctx.drawImage(canvas, 0, -yPos)
if (yPos > 0) pdf.addPage()
pdf.addImage(sliceCanvas.toDataURL('image/png'), 'PNG', 0, 0, pageW, pageH)
yPos += sliceH
}
}
pdf.save(`Laporan_${pengguna.nama_anak}_${tanggalUpload.replace(/ /g, '_')}.pdf`)
} finally {
setLoading(false)
}
}
return (
<>
{/* ─── Hidden PDF Template ─── */}
<div style={{ position: 'fixed', top: '-9999px', left: '-9999px', zIndex: -1 }}>
<div
ref={templateRef}
style={{
width: 794,
backgroundColor: '#ffffff',
fontFamily: 'Arial, Helvetica, sans-serif',
color: '#111111',
padding: '48px 56px',
boxSizing: 'border-box',
}}
>
{/* ── Header ── */}
<div style={{ borderBottom: '3px solid #111', paddingBottom: 20, marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>
Sistem Informasi Posyandu
</div>
<div style={{ fontSize: 22, fontWeight: 900, letterSpacing: -0.5 }}>
Laporan Pemeriksaan Balita
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#888', marginBottom: 3 }}>Tanggal Cetak</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalCetak}</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Tgl Pemeriksaan</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalUpload}</div>
</div>
</div>
{/* ── Identitas ── */}
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
Identitas
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px' }}>
{[
['Nama Ibu / Orang Tua', pengguna.nama_orang_tua],
['Nama Anak', pengguna.nama_anak],
['Alamat', pengguna.alamat ?? '-'],
['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'],
['', ''],
['Tanggal Lahir', tanggalLahir],
].map(([label, value], i) => (
label ? (
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 8 }}>
<div style={{ fontSize: 9, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 3 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
</div>
) : <div key={i} />
))}
</div>
</div>
{/* ── Charts ── */}
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
Grafik Perkembangan Balita (5 Bulan Terakhir)
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{/* Tinggi */}
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 12, padding: '14px 14px 4px', background: '#eff6ff' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#1d4ed8', marginBottom: 8 }}>📏 Tinggi Badan (cm)</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="pdfTinggiGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)"
dot={{ r: 4, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Berat */}
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 12, padding: '14px 14px 4px', background: '#f0fdf4' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#059669', marginBottom: 8 }}> Berat Badan (kg)</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="pdfBeratGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)"
dot={{ r: 4, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* ── Data Pemeriksaan ── */}
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
Data Pemeriksaan
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#111', color: '#fff' }}>
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
<th key={h} style={{ padding: '10px 12px', fontWeight: 700, fontSize: 10, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
<tr style={{ background: '#f9fafb' }}>
<td style={{ padding: '12px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td>
<td style={{ padding: '12px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td>
<td style={{ padding: '12px' }}>
<span style={{
display: 'inline-block',
padding: '3px 10px',
borderRadius: 20,
fontSize: 11,
fontWeight: 700,
background: isStunting ? '#fef2f2' : '#f0fdf4',
color: isStunting ? '#b91c1c' : '#15803d',
border: `1px solid ${isStunting ? '#fecaca' : '#bbf7d0'}`,
}}>
{isStunting ? '⚠ Stunting' : '✓ Normal'}
</span>
</td>
<td style={{ padding: '12px' }}>{row.nama_posyandu ?? '-'}</td>
<td style={{ padding: '12px' }}>{tanggalUpload}</td>
</tr>
</tbody>
</table>
{/* Pesan AI */}
{row.pesan_ai && (
<div style={{ marginTop: 12, border: '1.5px solid #fde68a', borderRadius: 10, padding: '12px 16px', background: '#fffbeb' }}>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 5 }}>
Pesan Kecerdasan Buatan (AI)
</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#78350f' }}>{row.pesan_ai}</div>
</div>
)}
</div>
{/* ── Footer / Tanda Tangan ── */}
<div style={{ borderTop: '1.5px solid #e5e7eb', paddingTop: 24, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40 }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 48 }}>Mengetahui,</div>
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}>
<div style={{ fontSize: 10, color: '#555' }}>Kepala Puskesmas / Supervisor</div>
</div>
</div>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 48 }}>Petugas Posyandu,</div>
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}>
<div style={{ fontSize: 10, color: '#555' }}>Nama &amp; Tanda Tangan</div>
</div>
</div>
</div>
{/* ── Doc footer ── */}
<div style={{ marginTop: 24, paddingTop: 12, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', fontSize: 9, color: '#bbb' }}>
<span>Dicetak oleh Sistem Informasi Posyandu</span>
<span>Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem</span>
</div>
</div>
</div>
{/* ─── Visible Button ─── */}
<button
onClick={handlePrint}
disabled={loading}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-gray-800 text-white text-xs font-bold rounded-lg hover:bg-black transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-3 h-3" />
{loading ? 'Memproses...' : 'PDF'}
</button>
</>
)
}

View File

@ -0,0 +1,182 @@
'use client'
import { useState, useMemo } from 'react'
import { Activity, ChevronDown } from 'lucide-react'
import { CetakPDFButton } from './CetakPDFButton'
interface HasilStunting {
id: number
tinggi_badan: number | null
berat_badan: number | null
status_stunting: boolean | null
pesan_ai: string | null
tanggal_upload: string | null
nama_posyandu: string | null
}
interface Pengguna {
nama_orang_tua: string
alamat: string | null
nama_anak: string
jenis_kelamin: string | null
tanggal_lahir: string | null
}
interface Props {
data: HasilStunting[]
pengguna: Pengguna
}
const START_YEAR = 2026
export function HasilStuntingTable({ data, pengguna }: Props) {
const availableYears = useMemo(() => {
const currentYear = new Date().getFullYear()
const dataYears = Array.from(
new Set(data.map(d => d.tanggal_upload ? new Date(d.tanggal_upload).getFullYear() : null).filter(Boolean))
) as number[]
const maxYear = Math.max(currentYear, ...dataYears, START_YEAR)
return Array.from(
new Set([
...Array.from({ length: maxYear - START_YEAR + 1 }, (_, i) => START_YEAR + i),
...dataYears,
])
).filter(y => y >= START_YEAR).sort((a, b) => a - b)
}, [data])
const [selectedYear, setSelectedYear] = useState<number>(availableYears[0] ?? START_YEAR)
const filtered = useMemo(() => {
return data.filter(d => {
if (!d.tanggal_upload) return false
return new Date(d.tanggal_upload).getFullYear() === selectedYear
})
}, [data, selectedYear])
const formatDate = (d: string | null) => {
if (!d) return '-'
return new Date(d).toLocaleDateString('id-ID', {
day: 'numeric', month: 'short', year: 'numeric'
})
}
return (
<div className="flex flex-col gap-4">
{/* Section Header + Filter */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-gray-500" />
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">Riwayat Hasil Pengukuran</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-400">Periode:</span>
<div className="relative">
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="appearance-none border-2 border-black rounded-lg pl-3 pr-8 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none text-gray-500" />
</div>
<span className="text-xs text-gray-400 font-semibold">{filtered.length} data</span>
</div>
</div>
{/* Table */}
<div className="rounded-xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[36px_88px_88px_100px_1fr_108px_120px_90px] bg-black text-white px-4 py-3 text-[10px] font-bold uppercase tracking-widest">
<span className="text-center text-gray-500">#</span>
<span className="text-center">Tinggi</span>
<span className="text-center">Berat</span>
<span className="text-center">Status</span>
<span>Pesan AI</span>
<span className="text-center">Posyandu</span>
<span className="text-center">Tgl Upload</span>
<span className="text-center">Aksi</span>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-gray-300">
<Activity className="w-10 h-10 opacity-30" />
<p className="text-sm text-gray-400 font-semibold">Tidak ada data untuk tahun {selectedYear}</p>
</div>
) : (
filtered.map((row, idx) => {
const isStunting = row.status_stunting === true
return (
<div
key={row.id}
className={`grid grid-cols-[36px_88px_88px_100px_1fr_108px_120px_90px] items-center px-4 py-3 border-b border-gray-100 text-sm transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/40'} hover:bg-orange-50/30`}
>
{/* Index */}
<div className="flex justify-center">
<span className="text-xs text-gray-400 font-bold">{idx + 1}</span>
</div>
{/* Tinggi Badan */}
<div className="text-center">
<span className="font-bold">{row.tinggi_badan ?? '-'}</span>
{row.tinggi_badan && <span className="text-xs text-gray-400 ml-0.5">cm</span>}
</div>
{/* Berat Badan */}
<div className="text-center">
<span className="font-bold">{row.berat_badan ?? '-'}</span>
{row.berat_badan && <span className="text-xs text-gray-400 ml-0.5">kg</span>}
</div>
{/* Status Stunting */}
<div className="flex justify-center">
{row.status_stunting === null ? (
<span className="text-xs text-gray-300"></span>
) : (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${isStunting
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
}`}>
{isStunting ? '⚠ Stunting' : '✓ Normal'}
</span>
)}
</div>
{/* Pesan AI — truncated */}
<div className="pr-2">
{row.pesan_ai ? (
<p className="text-xs text-gray-600 leading-relaxed">
{row.pesan_ai.length > 80
? row.pesan_ai.slice(0, 80) + '..........'
: row.pesan_ai}
</p>
) : (
<span className="text-xs text-gray-300 italic">Tidak ada pesan</span>
)}
</div>
{/* Nama Posyandu */}
<div className="text-center">
<span className="text-xs text-gray-600">{row.nama_posyandu ?? '-'}</span>
</div>
{/* Tanggal Upload */}
<div className="text-center">
<span className="text-xs text-gray-500">{formatDate(row.tanggal_upload)}</span>
</div>
{/* Aksi: Cetak PDF */}
<div className="flex justify-center">
<CetakPDFButton row={row} allData={data} pengguna={pengguna} />
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@ -0,0 +1,195 @@
'use client'
import { useState, useMemo } from 'react'
import {
AreaChart, Area,
XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer,
} from 'recharts'
import { Ruler, Weight, ChevronDown } from 'lucide-react'
interface HasilItem {
tinggi_badan: number | null
berat_badan: number | null
tanggal_upload: string | null
}
interface Props {
data: HasilItem[]
}
const START_YEAR = 2026
function MiniTooltip({ active, payload, label, unit, color }: any) {
if (!active || !payload?.length) return null
return (
<div className={`bg-white border-2 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-3 py-2 text-xs`}
style={{ borderColor: color }}>
<p className="font-bold text-gray-700 mb-1">{label}</p>
<p className="font-black" style={{ color }}>
{payload[0]?.value ?? '-'} <span className="font-normal text-gray-400">{unit}</span>
</p>
</div>
)
}
export function PerkembanganChart({ data }: Props) {
const availableYears = useMemo(() => {
const currentYear = new Date().getFullYear()
const dataYears = Array.from(
new Set(
data
.map(d => d.tanggal_upload ? new Date(d.tanggal_upload).getFullYear() : null)
.filter(Boolean)
)
) as number[]
const maxYear = Math.max(currentYear, ...dataYears, START_YEAR)
return Array.from(
new Set([
...Array.from({ length: maxYear - START_YEAR + 1 }, (_, i) => START_YEAR + i),
...dataYears,
])
).filter(y => y >= START_YEAR).sort((a, b) => a - b)
}, [data])
const [selectedYear, setSelectedYear] = useState<number>(availableYears[0] ?? START_YEAR)
const chartData = useMemo(() => {
return data
.filter(d => {
if (!d.tanggal_upload) return false
return new Date(d.tanggal_upload).getFullYear() === selectedYear
})
.map(d => ({
label: new Date(d.tanggal_upload!).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }),
// Store full date for sorting
_date: new Date(d.tanggal_upload!).getTime(),
tinggi: d.tinggi_badan,
berat: d.berat_badan,
}))
.sort((a, b) => a._date - b._date)
}, [data, selectedYear])
const hasData = chartData.length > 0
return (
<div className="flex flex-col gap-4">
{/* Section header + filter */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-400 mt-[3px]" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-400 mt-[3px]" />
</div>
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">
Grafik Perkembangan Balita
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-400">Periode:</span>
<div className="relative">
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="appearance-none border-2 border-black rounded-lg pl-3 pr-8 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none text-gray-500" />
</div>
</div>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tinggi Badan */}
<div className="rounded-xl border-2 border-blue-100 bg-blue-50/30 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 border border-blue-200 flex items-center justify-center">
<Ruler className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-bold text-blue-800">Tinggi Badan</p>
<p className="text-[10px] text-blue-400">Dalam satuan cm</p>
</div>
</div>
{!hasData ? (
<div className="h-36 flex items-center justify-center text-gray-300 text-xs">
Tidak ada data untuk tahun {selectedYear}
</div>
) : (
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="tinggiGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.25} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#dbeafe" />
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip content={<MiniTooltip unit="cm" color="#3b82f6" />} />
<Area
type="monotone"
dataKey="tinggi"
stroke="#3b82f6"
strokeWidth={2.5}
fill="url(#tinggiGrad)"
dot={{ r: 4, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Berat Badan */}
<div className="rounded-xl border-2 border-emerald-100 bg-emerald-50/30 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<Weight className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-bold text-emerald-800">Berat Badan</p>
<p className="text-[10px] text-emerald-400">Dalam satuan kg</p>
</div>
</div>
{!hasData ? (
<div className="h-36 flex items-center justify-center text-gray-300 text-xs">
Tidak ada data untuk tahun {selectedYear}
</div>
) : (
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="beratGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#d1fae5" />
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip content={<MiniTooltip unit="kg" color="#10b981" />} />
<Area
type="monotone"
dataKey="berat"
stroke="#10b981"
strokeWidth={2.5}
fill="url(#beratGrad)"
dot={{ r: 4, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,197 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, User, MapPin, Phone, Baby, Calendar } from 'lucide-react'
import Link from 'next/link'
import { Mars, Venus } from 'lucide-react'
import { HasilStuntingTable } from './HasilStuntingTable'
import { PerkembanganChart } from './PerkembanganChart'
interface Props {
params: Promise<{ id: string }>
}
function ReadField({
icon,
label,
value,
accent,
}: {
icon: React.ReactNode
label: string
value: string | null | undefined
accent?: 'blue' | 'pink'
}) {
return (
<div className="flex flex-col gap-1.5 p-4 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:border-gray-200 transition-colors">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-400">
<span className="text-gray-400">{icon}</span>
{label}
</div>
<p className={`text-base font-bold ${accent === 'blue' ? 'text-blue-600' : accent === 'pink' ? 'text-pink-600' : 'text-black'}`}>
{value || <span className="text-gray-300 font-normal italic">Tidak ada data</span>}
</p>
</div>
)
}
export default async function DetailPenggunaKelolaPage({ params }: Props) {
const { id } = await params
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
const { data: pengguna, error } = await supabase
.from('akun_balita')
.select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, jenis_kelamin, tanggal_lahir')
.eq('id', id)
.single()
if (error || !pengguna) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold">
Data tidak ditemukan.
</div>
)
}
// Fetch hasil pengukuran stunting milik balita ini
const { data: hasilData } = await supabase
.from('hasil_stunting_balita')
.select('id, tinggi_badan, berat_badan, status_stunting, pesan_ai, tanggal_upload, nama_posyandu')
.eq('id_balita', pengguna.id)
.order('tanggal_upload', { ascending: false })
const formatDate = (d: string | null) => {
if (!d) return null
return new Date(d).toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric'
})
}
const isLaki = pengguna.jenis_kelamin?.toLowerCase().includes('laki')
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard/kelola-data" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali ke Daftar</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Detail Pengguna</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">REVIEW DATA</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-5xl mx-auto flex-1 w-full">
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
{/* Card Hero */}
<div className="bg-black text-white px-8 py-6 flex items-center gap-5">
<div className="w-16 h-16 rounded-full bg-white/10 border-2 border-white/20 flex items-center justify-center text-2xl font-black flex-shrink-0">
{pengguna.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<h2 className="text-2xl font-black">{pengguna.nama_orang_tua}</h2>
<div className="flex items-center gap-2 mt-1.5">
<Baby className="w-3.5 h-3.5 text-gray-400" />
<span className="text-sm text-gray-300">{pengguna.nama_anak ?? '-'}</span>
</div>
</div>
</div>
<div className="p-8 flex flex-col gap-8">
{/* Section: Data Orang Tua */}
<div>
<div className="flex items-center gap-2 mb-4">
<User className="w-4 h-4 text-gray-500" />
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">Data Orang Tua</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<ReadField icon={<User className="w-3.5 h-3.5" />} label="Nama Ibu / Orang Tua" value={pengguna.nama_orang_tua} />
</div>
<div className="md:col-span-2">
<ReadField icon={<MapPin className="w-3.5 h-3.5" />} label="Alamat" value={pengguna.alamat} />
</div>
<div className="md:col-span-2">
<ReadField icon={<Phone className="w-3.5 h-3.5" />} label="No. WhatsApp" value={pengguna.no_whatsapp} />
</div>
</div>
</div>
<div className="border-t-2 border-dashed border-gray-100" />
{/* Section: Data Anak */}
<div>
<div className="flex items-center gap-2 mb-4">
<Baby className="w-4 h-4 text-gray-500" />
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">Data Anak</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<ReadField icon={<Baby className="w-3.5 h-3.5" />} label="Nama Anak" value={pengguna.nama_anak} />
</div>
<ReadField
icon={isLaki ? <Mars className="w-3.5 h-3.5" /> : <Venus className="w-3.5 h-3.5" />}
label="Jenis Kelamin"
value={pengguna.jenis_kelamin}
accent={isLaki ? 'blue' : 'pink'}
/>
<ReadField
icon={<Calendar className="w-3.5 h-3.5" />}
label="Tanggal Lahir"
value={formatDate(pengguna.tanggal_lahir)}
/>
</div>
</div>
{/* Separator: Perkembangan */}
<div className="flex items-center gap-4 py-2">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-gray-200" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-gray-50 border border-gray-200 rounded-full">
<div className="w-1.5 h-1.5 rounded-full bg-blue-300" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Grafik Perkembangan</span>
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300" />
</div>
<div className="flex-1 h-px bg-gradient-to-l from-transparent via-gray-200 to-gray-200" />
</div>
{/* Chart Perkembangan Tinggi & Berat */}
<PerkembanganChart data={hasilData ?? []} />
{/* Professional Separator */}
<div className="flex items-center gap-4 py-2">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-gray-200" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-gray-50 border border-gray-200 rounded-full">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Riwayat Pengukuran</span>
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
</div>
<div className="flex-1 h-px bg-gradient-to-l from-transparent via-gray-200 to-gray-200" />
</div>
{/* Tabel Riwayat Hasil Stunting */}
<HasilStuntingTable data={hasilData ?? []} pengguna={pengguna} />
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, ClipboardList } from 'lucide-react'
import Link from 'next/link'
import { KelolaDataTable } from './KelolaDataTable'
import { CetakInstanModal } from './CetakInstanModal'
export default async function KelolaDataPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
const { data, error } = await supabase
.from('akun_balita')
.select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, jenis_kelamin, tanggal_lahir')
.order('nama_orang_tua', { ascending: true })
if (error) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold">
Gagal memuat data.
</div>
)
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Kelola Data</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DATA PENGGUNA TERDAFTAR</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-5xl mx-auto flex-1 w-full">
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
{/* Card Header */}
<div className="flex items-center gap-4 mb-8 pb-6 border-b border-gray-100">
<div className="w-14 h-14 rounded-full bg-orange-50 border-2 border-orange-200 flex items-center justify-center text-orange-600">
<ClipboardList className="w-7 h-7" />
</div>
<div>
<h2 className="text-2xl font-bold">Daftar Data Pengguna</h2>
<p className="text-sm text-gray-500">
Review informasi lengkap data akun pengguna yang terdaftar
</p>
</div>
<div className="ml-auto flex items-center gap-3">
<CetakInstanModal />
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg">
<span className="text-2xl font-black">{data?.length ?? 0}</span>
<span className="text-xs text-gray-500 font-semibold">Total<br />Pengguna</span>
</div>
</div>
</div>
<KelolaDataTable data={data ?? []} />
</div>
</main>
</div>
)
}

View File

@ -34,7 +34,7 @@ export default function ManajemenAkunPage() {
title="Kelola Akun Petugas"
description="Kelola akun anda sebagai petugas. Ubah profil, password, dan informasi petugas lainnya."
icon={UserCog}
href="#"
href="/dashboard/manajemen-akun/petugas"
color="green"
className="h-full"
/>
@ -46,7 +46,7 @@ export default function ManajemenAkunPage() {
title="Kelola Akun Pengguna"
description="Kelola data akun pengguna (Masyarakat). Reset password, pemblokiran, dan manajemen akses."
icon={Users}
href="#"
href="/dashboard/manajemen-akun/pengguna"
color="blue"
className="h-full"
/>

View File

@ -0,0 +1,169 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { updateAkunBalita } from '@/app/actions'
import { User, MapPin, Phone, Baby, Calendar, Lock, AtSign, CheckCircle, XCircle, X } from 'lucide-react'
interface AkunBalita {
id: string
nama_orang_tua: string
alamat: string | null
no_whatsapp: string | null
nama_anak: string
tanggal_lahir: string | null
username: string
password: string
}
interface Props {
pengguna: AkunBalita
}
interface ToastProps {
message: string
type: 'success' | 'error'
onClose: () => void
}
function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, 4000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div
className={`fixed top-6 right-6 z-50 flex items-center gap-3 px-5 py-4 rounded-xl border-2 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]
${type === 'success'
? 'bg-green-50 border-green-500 text-green-800'
: 'bg-red-50 border-red-500 text-red-800'
}`}
style={{ animation: 'slideIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)' }}
>
{type === 'success'
? <CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
: <XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
}
<span className="font-semibold text-sm">{message}</span>
<button onClick={onClose} className="ml-2 p-1 rounded-full hover:bg-black/10 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
)
}
export function EditPenggunaForm({ pengguna }: Props) {
const [state, formAction, isPending] = useActionState(updateAkunBalita, null)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
useEffect(() => {
if (state) {
setToast({ message: state.message, type: state.success ? 'success' : 'error' })
}
}, [state])
const inputClass = "border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-black transition-colors w-full text-sm"
const labelClass = "text-sm font-bold flex items-center gap-2 mb-1"
return (
<>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
<style>{`
@keyframes slideIn {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
<form action={formAction} className="flex flex-col gap-5">
<input type="hidden" name="id" value={pengguna.id} />
{/* Section: Data Orang Tua */}
<div className="pb-2 mb-1 border-b border-gray-100">
<p className="text-xs font-bold uppercase tracking-widest text-gray-400">Data Orang Tua</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Nama Orang Tua */}
<div>
<label className={labelClass}><User className="w-4 h-4" /> Nama Orang Tua <span className="text-red-400">*</span></label>
<input type="text" name="nama_orang_tua" defaultValue={pengguna.nama_orang_tua} className={inputClass} required />
</div>
{/* No WhatsApp */}
<div>
<label className={labelClass}><Phone className="w-4 h-4" /> No. WhatsApp</label>
<input type="tel" name="no_whatsapp" defaultValue={pengguna.no_whatsapp || ''} className={inputClass} placeholder="08xxxxxxxxxx" />
</div>
</div>
{/* Alamat */}
<div>
<label className={labelClass}><MapPin className="w-4 h-4" /> Alamat</label>
<textarea
name="alamat"
defaultValue={pengguna.alamat || ''}
className={`${inputClass} resize-none`}
rows={3}
placeholder="Masukkan alamat lengkap..."
/>
</div>
{/* Section: Data Anak */}
<div className="pb-2 mb-1 border-b border-gray-100 mt-2">
<p className="text-xs font-bold uppercase tracking-widest text-gray-400">Data Anak</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Nama Anak */}
<div>
<label className={labelClass}><Baby className="w-4 h-4" /> Nama Anak <span className="text-red-400">*</span></label>
<input type="text" name="nama_anak" defaultValue={pengguna.nama_anak} className={inputClass} required />
</div>
{/* Tanggal Lahir */}
<div>
<label className={labelClass}><Calendar className="w-4 h-4" /> Tanggal Lahir</label>
<input
type="date"
name="tanggal_lahir"
defaultValue={pengguna.tanggal_lahir || ''}
className={inputClass}
/>
</div>
</div>
{/* Section: Akun */}
<div className="pb-2 mb-1 border-b border-gray-100 mt-2">
<p className="text-xs font-bold uppercase tracking-widest text-gray-400">Data Akun</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Username */}
<div>
<label className={labelClass}><AtSign className="w-4 h-4" /> Username <span className="text-red-400">*</span></label>
<input type="text" name="username" defaultValue={pengguna.username} className={inputClass} required />
</div>
{/* Password */}
<div>
<label className={labelClass}><Lock className="w-4 h-4" /> Password <span className="text-red-400">*</span></label>
<input type="text" name="password" defaultValue={pengguna.password} className={inputClass} required />
<p className="text-[10px] text-gray-400 mt-1">Pastikan password aman dan mudah diingat.</p>
</div>
</div>
{/* Submit */}
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full bg-black text-white font-bold py-4 rounded-lg hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.2)] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.2)] hover:translate-x-[2px] hover:translate-y-[2px] disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
>
{isPending ? 'Menyimpan...' : 'Simpan Perubahan'}
</button>
</div>
</form>
</>
)
}

View File

@ -0,0 +1,81 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Users } from 'lucide-react'
import Link from 'next/link'
import { EditPenggunaForm } from './EditPenggunaForm'
interface Props {
params: Promise<{ id: string }>
}
export default async function DetailPenggunaPage({ params }: Props) {
const { id } = await params
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
const { data: pengguna, error } = await supabase
.from('akun_balita')
.select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, tanggal_lahir, username, password')
.eq('id', id)
.single()
if (error || !pengguna) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold">
Data pengguna tidak ditemukan.
</div>
)
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard/manajemen-akun/pengguna" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali ke Daftar</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Edit Akun Pengguna</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DETAIL & EDIT DATA</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-3xl mx-auto flex-1 w-full">
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
{/* Card Header */}
<div className="flex items-center gap-4 mb-8 pb-6 border-b border-gray-100">
<div className="w-16 h-16 rounded-full bg-blue-50 border-2 border-blue-200 flex items-center justify-center text-blue-600 text-2xl font-bold">
{pengguna.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<h2 className="text-2xl font-bold">{pengguna.nama_orang_tua}</h2>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs bg-blue-100 border border-blue-200 text-blue-700 px-2 py-0.5 rounded-full font-semibold flex items-center gap-1">
<Users className="w-3 h-3" /> Pengguna
</span>
<span className="text-xs text-gray-400 font-mono">@{pengguna.username}</span>
</div>
</div>
</div>
<EditPenggunaForm pengguna={pengguna} />
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,142 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Users, Search, Eye, Baby, Phone } from 'lucide-react'
import Link from 'next/link'
export default async function KelolaAkunPenggunaPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
const { data: pengguna, error } = await supabase
.from('akun_balita')
.select('id, nama_orang_tua, nama_anak, no_whatsapp, username, tanggal_lahir, created_at')
.order('created_at', { ascending: false })
if (error) {
return <div className="p-8 text-red-500">Gagal memuat data pengguna.</div>
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard/manajemen-akun" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Kelola Akun Pengguna</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DAFTAR PENGGUNA TERDAFTAR</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full">
{/* Stats Bar */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-50 border-2 border-blue-200 flex items-center justify-center text-blue-600">
<Users className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold leading-none">{pengguna?.length ?? 0}</p>
<p className="text-xs text-gray-500">Total Pengguna</p>
</div>
</div>
<div className="flex items-center gap-2 px-4 py-2 border-2 border-gray-200 rounded-lg text-sm text-gray-500">
<Search className="w-4 h-4" />
<span>Cari melalui halaman detail</span>
</div>
</div>
{/* Table */}
<div className="rounded-xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_auto] bg-black text-white px-6 py-4 text-xs font-bold uppercase tracking-widest">
<span>Nama Orang Tua</span>
<span>Nama Anak</span>
<span>No. WhatsApp</span>
<span>Username</span>
<span className="text-center">Aksi</span>
</div>
{/* Table Rows */}
{!pengguna || pengguna.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-400">
<Users className="w-12 h-12 opacity-30" />
<p className="font-semibold">Belum ada pengguna terdaftar</p>
</div>
) : (
pengguna.map((user, idx) => (
<div
key={user.id}
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_auto] items-center px-6 py-4 border-b border-gray-100 hover:bg-gray-50 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}`}
>
{/* Nama Orang Tua */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 text-xs font-bold flex-shrink-0">
{user.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'}
</div>
<div>
<p className="font-semibold text-sm leading-none">{user.nama_orang_tua ?? '-'}</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{user.created_at ? new Date(user.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) : '-'}
</p>
</div>
</div>
{/* Nama Anak */}
<div className="flex items-center gap-2">
<Baby className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
<span className="text-sm truncate">{user.nama_anak ?? '-'}</span>
</div>
{/* No WA */}
<div className="flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-600">{user.no_whatsapp ?? '-'}</span>
</div>
{/* Username */}
<div>
<span className="inline-block bg-gray-100 border border-gray-200 text-gray-700 text-xs font-mono px-2 py-1 rounded">
@{user.username}
</span>
</div>
{/* Aksi */}
<div className="flex justify-center">
<Link
href={`/dashboard/manajemen-akun/pengguna/${user.id}`}
className="flex items-center gap-1.5 px-4 py-2 bg-black text-white text-xs font-bold rounded-lg hover:bg-gray-800 transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px]"
>
<Eye className="w-3.5 h-3.5" />
Detail
</Link>
</div>
</div>
))
)}
</div>
{/* Footer note */}
<p className="text-xs text-gray-400 text-center mt-4">
Klik <strong>Detail</strong> untuk melihat dan mengedit data pengguna
</p>
</main>
</div>
)
}

View File

@ -0,0 +1,161 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { updatePetugas } from '@/app/actions'
import { User, Phone, Lock, UserCog, CheckCircle, XCircle, X } from 'lucide-react'
interface PetugasData {
id: string
nama: string
username: string
no_telp: string | null
password: string
}
interface Props {
petugas: PetugasData
}
interface ToastProps {
message: string
type: 'success' | 'error'
onClose: () => void
}
function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, 4000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div
className={`fixed top-6 right-6 z-50 flex items-center gap-3 px-5 py-4 rounded-xl border-2 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] animate-slide-in
${type === 'success'
? 'bg-green-50 border-green-500 text-green-800'
: 'bg-red-50 border-red-500 text-red-800'
}`}
style={{ animation: 'slideIn 0.3s ease-out' }}
>
{type === 'success'
? <CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
: <XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
}
<span className="font-semibold text-sm">{message}</span>
<button
onClick={onClose}
className="ml-2 p-1 rounded-full hover:bg-black/10 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function EditPetugasForm({ petugas }: Props) {
const [state, formAction, isPending] = useActionState(updatePetugas, null)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
useEffect(() => {
if (state) {
setToast({
message: state.message,
type: state.success ? 'success' : 'error',
})
}
}, [state])
return (
<>
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
<style>{`
@keyframes slideIn {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
<form action={formAction} className="flex flex-col gap-6">
<input type="hidden" name="id" value={petugas.id} />
{/* Nama */}
<div className="flex flex-col gap-2">
<label className="text-sm font-bold flex items-center gap-2">
<User className="w-4 h-4" />
Nama Lengkap
</label>
<input
type="text"
name="nama"
defaultValue={petugas.nama}
className="border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-black transition-colors"
required
/>
</div>
{/* Username */}
<div className="flex flex-col gap-2">
<label className="text-sm font-bold flex items-center gap-2">
<UserCog className="w-4 h-4" />
Username
</label>
<input
type="text"
name="username"
defaultValue={petugas.username}
className="border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-black transition-colors"
required
/>
</div>
{/* No Telp */}
<div className="flex flex-col gap-2">
<label className="text-sm font-bold flex items-center gap-2">
<Phone className="w-4 h-4" />
Nomor Telepon
</label>
<input
type="tel"
name="no_telp"
defaultValue={petugas.no_telp || ''}
className="border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-black transition-colors"
/>
</div>
{/* Password */}
<div className="flex flex-col gap-2">
<label className="text-sm font-bold flex items-center gap-2">
<Lock className="w-4 h-4" />
Password
</label>
<input
type="text"
name="password"
defaultValue={petugas.password}
className="border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-black transition-colors"
required
/>
<p className="text-[10px] text-gray-400">Pastikan password anda aman dan mudah diingat.</p>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full bg-black text-white font-bold py-4 rounded-lg hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.2)] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.2)] hover:translate-x-[2px] hover:translate-y-[2px] disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
>
{isPending ? 'Menyimpan...' : 'Simpan Perubahan'}
</button>
</div>
</form>
</>
)
}

View File

@ -0,0 +1,72 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, UserCog } from 'lucide-react'
import Link from 'next/link'
import { EditPetugasForm } from './EditPetugasForm'
export default async function KelolaAkunPetugasPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) {
redirect('/')
}
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') {
redirect('/dashboard')
}
const { data: petugas, error } = await supabase
.from('petugas_posyandu')
.select('*')
.eq('id', session.id)
.single()
if (error || !petugas) {
return <div>Error loading profile.</div>
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard/manajemen-akun" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Kelola Akun Petugas</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">EDIT PROFIL ANDA</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-2xl mx-auto flex-1 w-full">
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
<div className="flex items-center gap-4 mb-8 pb-6 border-b border-gray-100">
<div className="w-16 h-16 rounded-full bg-green-50 border-2 border-green-200 flex items-center justify-center text-green-600">
<UserCog className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Informasi Akun</h2>
<p className="text-gray-500 text-sm">Perbarui informasi akun petugas anda di sini.</p>
</div>
</div>
<EditPetugasForm petugas={petugas} />
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import { useState, useMemo } from 'react'
import { Search, Plus, MapPin, Phone, User, Edit3, Trash2, Eye, ExternalLink, Building2 } from 'lucide-react'
import { PosyanduFormModal } from './PosyanduFormModal'
import { useRouter } from 'next/navigation'
import { supabase } from '@/lib/supabase'
interface PosyanduWithPetugas {
id: string
nama_posyandu: string
alamat: string
kontak: string | null
latitude: number | null
longitude: number | null
link_google_maps: string | null
petugas?: {
nama_petugas: string
nomor_hp: string | null
jabatan: string | null
}[]
}
interface Props {
data: PosyanduWithPetugas[]
}
export function ManajemenPosyanduTable({ data }: Props) {
const router = useRouter()
const [searchTerm, setSearchTerm] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const [selectedPosyandu, setSelectedPosyandu] = useState<PosyanduWithPetugas | null>(null)
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const filteredData = useMemo(() => {
return data.filter(p =>
p.nama_posyandu.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.alamat.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.petugas?.[0]?.nama_petugas?.toLowerCase().includes(searchTerm.toLowerCase()))
)
}, [data, searchTerm])
const handleEdit = (posyandu: PosyanduWithPetugas) => {
setSelectedPosyandu(posyandu)
setIsModalOpen(true)
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Apakah Anda yakin ingin menghapus data Posyandu ${name}?`)) return
setIsDeleting(id)
try {
// First delete local petugas (due to FK)
await supabase.from('petugas_posyandu_lokal').delete().eq('posyandu_id', id)
// Then delete the posyandu
const { error } = await supabase.from('detail_posyandu').delete().eq('id', id)
if (error) throw error
router.refresh()
} catch (err: any) {
alert('Gagal menghapus data: ' + err.message)
} finally {
setIsDeleting(null)
}
}
return (
<div className="flex flex-col gap-6">
{/* Action Bar */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama posyandu, alamat, atau petugas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-semibold outline-none transition-all"
/>
</div>
<button
onClick={() => { setSelectedPosyandu(null); setIsModalOpen(true) }}
className="w-full md:w-auto flex items-center justify-center gap-2 px-6 py-2.5 bg-purple-600 text-white font-bold rounded-xl hover:bg-purple-700 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]"
>
<Plus className="w-4 h-4" />
Tambah Posyandu Baru
</button>
</div>
{/* Table */}
<div className="overflow-hidden rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<table className="w-full text-left border-collapse">
<thead className="bg-black text-white">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-16">No</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Informasi Posyandu</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Petugas & Kontak</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Lokasi</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-40">Aksi</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{filteredData.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-20 text-center">
<div className="flex flex-col items-center gap-2 text-gray-400">
<Building2 className="w-12 h-12 opacity-20" />
<p className="font-bold">Tidak ada data posyandu ditemukan</p>
</div>
</td>
</tr>
) : (
filteredData.map((p, idx) => (
<tr key={p.id} className="hover:bg-purple-50/30 transition-colors group">
<td className="px-6 py-5 text-center font-black text-gray-300 group-hover:text-purple-300">
{idx + 1}
</td>
<td className="px-6 py-5">
<div className="flex flex-col">
<span className="font-black text-base">{p.nama_posyandu}</span>
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1">
<MapPin className="w-3 h-3 flex-shrink-0" />
<span className="line-clamp-1">{p.alamat}</span>
</div>
</div>
</td>
<td className="px-6 py-5">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-purple-100 text-purple-600">
<User className="w-3 h-3" />
</div>
<span className="text-sm font-bold text-gray-700">
{p.petugas?.[0]?.nama_petugas || '-'}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Phone className="w-3 h-3" />
<span>{p.kontak || '-'}</span>
</div>
</div>
</td>
<td className="px-6 py-5 text-center">
{p.link_google_maps ? (
<a
href={p.link_google_maps}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold border border-blue-100 hover:bg-blue-100 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Cek Maps
</a>
) : (
<span className="text-xs text-gray-300 italic">Belum diset</span>
)}
</td>
<td className="px-6 py-5">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleEdit(p)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
title="Edit"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(p.id, p.nama_posyandu)}
disabled={isDeleting === p.id}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all disabled:opacity-50"
title="Hapus"
>
{isDeleting === p.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
<div className="w-px h-10 bg-gray-100 mx-1"></div>
<button
onClick={() => router.push(`/dashboard/manajemen-posyandu/review/${p.id}`)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-lg text-xs font-black shadow-[3px_3px_0px_0px_rgba(147,51,234,0.5)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px] transition-all"
>
<Eye className="w-3.5 h-3.5" />
REVIEW
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{isModalOpen && (
<PosyanduFormModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
selectedData={selectedPosyandu}
/>
)}
</div>
)
}
function Loader2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
}

View File

@ -0,0 +1,268 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Save, Building2, MapPin, Phone, User, Map as MapIcon, Loader2, Info } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useRouter } from 'next/navigation'
interface PosyanduFormModalProps {
isOpen: boolean
onClose: () => void
selectedData?: any | null
}
export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFormModalProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
nama_posyandu: '',
alamat: '',
kontak: '',
nama_petugas: '', // From petugas_posyandu_lokal
link_google_maps: '',
latitude: '',
longitude: ''
})
useEffect(() => {
if (selectedData) {
setFormData({
nama_posyandu: selectedData.nama_posyandu || '',
alamat: selectedData.alamat || '',
kontak: selectedData.kontak || '',
nama_petugas: selectedData.petugas?.[0]?.nama_petugas || '',
link_google_maps: selectedData.link_google_maps || '',
latitude: selectedData.latitude?.toString() || '',
longitude: selectedData.longitude?.toString() || ''
})
} else {
setFormData({
nama_posyandu: '',
alamat: '',
kontak: '',
nama_petugas: '',
link_google_maps: '',
latitude: '',
longitude: ''
})
}
}, [selectedData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const payload = {
nama_posyandu: formData.nama_posyandu,
alamat: formData.alamat,
kontak: formData.kontak,
link_google_maps: formData.link_google_maps,
latitude: formData.latitude ? parseFloat(formData.latitude) : null,
longitude: formData.longitude ? parseFloat(formData.longitude) : null,
}
let posyanduId = selectedData?.id
if (selectedData) {
// Update
const { error } = await supabase.from('detail_posyandu').update(payload).eq('id', selectedData.id)
if (error) throw error
} else {
// Insert
const { data, error } = await supabase.from('detail_posyandu').insert(payload).select().single()
if (error) throw error
posyanduId = data.id
}
// Sync Petugas (Simple logic: one main officer for this Posyandu)
if (formData.nama_petugas) {
const petugasPayload = {
posyandu_id: posyanduId,
nama_petugas: formData.nama_petugas,
jabatan: 'Koordinator'
}
if (selectedData?.petugas?.[0]?.id) {
await supabase.from('petugas_posyandu_lokal')
.update(petugasPayload)
.eq('id', selectedData.petugas[0].id)
} else {
await supabase.from('petugas_posyandu_lokal')
.insert(petugasPayload)
}
}
router.refresh()
onClose()
} catch (err: any) {
alert('Gagal menyimpan data: ' + err.message)
} finally {
setLoading(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-xl rounded-2xl border-2 border-black shadow-[12px_12px_0px_0px_rgba(0,0,0,1)] flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b-2 border-black bg-purple-600 text-white">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Building2 className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-xl font-bold leading-none">
{selectedData ? 'Edit Data Posyandu' : 'Tambah Posyandu Baru'}
</h2>
<p className="text-[10px] text-purple-200 uppercase tracking-widest mt-1">OPERASIONAL POSYANDU</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto flex flex-col gap-6">
{/* Nama Posyandu */}
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<Building2 className="w-3.5 h-3.5" />
Nama Posyandu
</label>
<input
required
type="text"
placeholder="Contoh: Posyandu Melati 01"
value={formData.nama_posyandu}
onChange={e => setFormData({ ...formData, nama_posyandu: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all"
/>
</div>
{/* Petugas & Kontak Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<User className="w-3.5 h-3.5" />
Nama Petugas
</label>
<input
required
type="text"
placeholder="Nama Koordinator"
value={formData.nama_petugas}
onChange={e => setFormData({ ...formData, nama_petugas: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<Phone className="w-3.5 h-3.5" />
Kontak / No HP
</label>
<input
type="text"
placeholder="0812xxxx"
value={formData.kontak}
onChange={e => setFormData({ ...formData, kontak: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all"
/>
</div>
</div>
{/* Alamat */}
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<MapPin className="w-3.5 h-3.5" />
Alamat Lengkap
</label>
<textarea
required
rows={3}
placeholder="Jl. Raya No. 123..."
value={formData.alamat}
onChange={e => setFormData({ ...formData, alamat: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all resize-none"
/>
</div>
{/* Google Maps Section */}
<div className="p-4 bg-blue-50/50 rounded-2xl border-2 border-blue-100 flex flex-col gap-4">
<div className="flex items-center gap-2 text-blue-700">
<MapIcon className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-widest">Titik Lokasi Google Maps</span>
</div>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Link Google Maps (Share Link)</label>
<input
type="url"
placeholder="https://maps.app.goo.gl/..."
value={formData.link_google_maps}
onChange={e => setFormData({ ...formData, link_google_maps: e.target.value })}
className="w-full px-4 py-2.5 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Latitude</label>
<input
type="text"
placeholder="-6.12345"
value={formData.latitude}
onChange={e => setFormData({ ...formData, latitude: e.target.value })}
className="w-full px-4 py-2 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Longitude</label>
<input
type="text"
placeholder="106.12345"
value={formData.longitude}
onChange={e => setFormData({ ...formData, longitude: e.target.value })}
className="w-full px-4 py-2 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
/>
</div>
</div>
<div className="flex items-start gap-2 text-[10px] text-blue-500 leading-relaxed italic">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>Input koordinat (Latitude & Longitude) agar titik lokasi akurat di sistem peta.</span>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 border-2 border-black rounded-xl font-bold text-sm hover:bg-gray-50 transition-all"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex-[2] py-3 bg-black text-white font-black text-sm rounded-xl hover:bg-gray-800 transition-all flex items-center justify-center gap-2 shadow-[4px_4px_0px_0px_rgba(147,51,234,0.5)]"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Simpan Data
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Building2 } from 'lucide-react'
import Link from 'next/link'
import { ManajemenPosyanduTable } from './ManajemenPosyanduTable'
export default async function ManajemenPosyanduPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
// Fetch detail_posyandu with its petugas
const { data: posyandu, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*)
`)
.order('nama_posyandu', { ascending: true })
if (error) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
<p className="font-black text-xl mb-2">Gagal Memuat Data</p>
<p className="text-sm opacity-80">{error.message}</p>
<Link href="/dashboard" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Dashboard</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none text-purple-600">Manajemen Posyandu</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">ADMINISTRASI WILAYAH</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-8">
{/* Hero Section */}
<div className="flex flex-col md:flex-row items-center gap-8 bg-purple-50 p-8 rounded-3xl border-2 border-purple-100 relative overflow-hidden">
<div className="flex-1 z-10">
<h2 className="text-3xl font-black mb-2 text-purple-900">Kelola Titik Layanan</h2>
<p className="text-purple-600/80 max-w-lg leading-relaxed font-semibold">
Tambah, edit, atau hapus lokasi posyandu yang aktif di wilayah ini. Pastikan titik koordinat Google Maps sudah sesuai untuk kemudahan navigasi warga.
</p>
</div>
<div className="w-20 h-20 bg-purple-600 rounded-2xl flex items-center justify-center text-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rotate-3 z-10">
<Building2 className="w-10 h-10" />
</div>
{/* Subtle decoration */}
<div className="absolute -right-10 -bottom-10 w-48 h-48 bg-purple-200/50 rounded-full blur-3xl"></div>
</div>
{/* Table Component */}
<ManajemenPosyanduTable data={posyandu ?? []} />
</main>
</div>
)
}

View File

@ -0,0 +1,210 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Building2, MapPin, Phone, User, ExternalLink, Calendar, Map as MapIcon } from 'lucide-react'
import Link from 'next/link'
interface Props {
params: Promise<{ id: string }>
}
export default async function ReviewPosyanduPage({ params }: Props) {
const { id } = await params
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
const { data: posyandu, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*)
`)
.eq('id', id)
.single()
if (error || !posyandu) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
<p className="font-black text-xl mb-2">Data Tidak Ditemukan</p>
<Link href="/dashboard/manajemen-posyandu" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Daftar Posyandu</Link>
</div>
</div>
)
}
const mapSrc = posyandu.latitude && posyandu.longitude
? `https://maps.google.com/maps?q=${posyandu.latitude},${posyandu.longitude}&z=15&output=embed`
: null
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard/manajemen-posyandu" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">{posyandu.nama_posyandu}</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DETAIL OPERASIONAL</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto w-full grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Information */}
<div className="lg:col-span-2 flex flex-col gap-8">
{/* Basic Info Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
<div className="p-8 bg-purple-600 border-b-2 border-black">
<h3 className="text-2xl font-black text-white flex items-center gap-3">
<Building2 className="w-7 h-7" />
Informasi Utama
</h3>
</div>
<div className="p-8 grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
<MapPin className="w-3 h-3" />
Alamat Posyandu
</span>
<p className="text-lg font-bold leading-relaxed">{posyandu.alamat}</p>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
<Phone className="w-3 h-3" />
Nomor Kontak
</span>
<p className="text-lg font-bold">{posyandu.kontak || '-'}</p>
</div>
</div>
</div>
{/* Petugas Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(147,51,234,0.3)] overflow-hidden">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-base font-black flex items-center gap-3">
<User className="w-5 h-5 text-purple-600" />
Daftar Petugas Bertugas
</h3>
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
{posyandu.petugas?.length || 0} Orang
</span>
</div>
<div className="divide-y divide-gray-100">
{posyandu.petugas && posyandu.petugas.length > 0 ? (
posyandu.petugas.map((pt: any) => (
<div key={pt.id} className="p-6 flex items-center justify-between hover:bg-gray-50/50 transition-all">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center font-black text-xl">
{pt.nama_petugas?.[0]}
</div>
<div>
<p className="font-bold text-gray-900">{pt.nama_petugas}</p>
<p className="text-xs text-gray-500 font-semibold uppercase tracking-wider">{pt.jabatan || 'Anggota'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-0.5">Kontak</p>
<p className="text-sm font-bold">{pt.nomor_hp || '-'}</p>
</div>
</div>
</div>
))
) : (
<div className="p-12 text-center text-gray-400 italic text-sm">
Belum ada data petugas terdaftar di posyandu ini.
</div>
)}
</div>
</div>
</div>
{/* Right Column - Map & Quick Sync */}
<div className="flex flex-col gap-8">
{/* Map Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(59,130,246,0.3)] overflow-hidden sticky top-8">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-blue-50/30">
<h3 className="text-base font-black flex items-center gap-3">
<MapIcon className="w-5 h-5 text-blue-600" />
Lokasi Geografis
</h3>
{posyandu.link_google_maps && (
<a
href={posyandu.link_google_maps}
target="_blank"
rel="noopener noreferrer"
className="p-2 bg-white text-blue-600 border border-blue-200 rounded-lg hover:shadow-md transition-all"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
<div className="aspect-square w-full bg-gray-100 relative">
{mapSrc ? (
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
src={mapSrc}
></iframe>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center gap-4 text-gray-400">
<MapIcon className="w-12 h-12 opacity-20" />
<p className="text-xs font-bold font-semibold px-4">
Koordinat GPS belum dikonfigurasi. Edit data untuk menambahkan Latitude & Longitude.
</p>
</div>
)}
</div>
<div className="p-6 bg-gray-50 border-t border-gray-100">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Latitude</span>
<p className="text-xs font-mono font-bold mt-1">{posyandu.latitude || '-'}</p>
</div>
<div>
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Longitude</span>
<p className="text-xs font-mono font-bold mt-1">{posyandu.longitude || '-'}</p>
</div>
</div>
</div>
</div>
{/* Stats Card */}
<div className="bg-black text-white rounded-3xl p-8 flex flex-col gap-4 relative overflow-hidden">
<div className="z-10">
<p className="text-purple-400 text-[10px] font-black uppercase tracking-widest mb-1">Status Operasional</p>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="text-xs font-bold uppercase tracking-wider">Aktif & Terpantau</span>
</div>
<p className="text-sm text-gray-400 leading-relaxed font-semibold">
Terakhir diperbarui pada {new Date(posyandu.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<Building2 className="absolute -right-8 -bottom-8 w-24 h-24 text-white/5" />
</div>
</div>
</main>
</div>
)
}

View File

@ -125,7 +125,7 @@ export default async function DashboardPage() {
title="Trend Stunting Daerah"
description="Analisis grafik dan data statistik stunting di wilayah kerja."
icon={TrendingUp}
href="#"
href="/dashboard/trend-stunting"
color="blue"
/>
@ -134,7 +134,7 @@ export default async function DashboardPage() {
title="Kelola Data"
description="Input, ubah, dan validasi data posyandu secara terpusat."
icon={ClipboardList}
href="#"
href="/dashboard/kelola-data"
color="orange"
/>
@ -143,7 +143,7 @@ export default async function DashboardPage() {
title="Manajemen Posyandu"
description="Pengaturan operasional dan administrasi posyandu."
icon={Building2}
href="#"
href="/dashboard/manajemen-posyandu"
color="purple"
/>

View File

@ -0,0 +1,395 @@
'use client'
import { useState, useMemo } from 'react'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
LineChart,
Line,
Area,
AreaChart,
} from 'recharts'
import { TrendingUp, TrendingDown, Minus, BarChart2, Activity, Percent } from 'lucide-react'
interface RawData {
status_stunting: boolean
tanggal_upload: string
nama_posyandu: string
}
interface Props {
data: RawData[]
availableYears: number[]
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Ags', 'Sep', 'Okt', 'Nov', 'Des']
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const stunting = payload.find((p: any) => p.dataKey === 'stunting')?.value ?? 0
const normal = payload.find((p: any) => p.dataKey === 'normal')?.value ?? 0
const total = stunting + normal
const pct = total > 0 ? ((stunting / total) * 100).toFixed(1) : '0.0'
return (
<div className="bg-white border-2 border-black rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
<p className="font-bold text-black mb-2">{label}</p>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />
Stunting
</span>
<span className="font-bold text-red-600">{stunting}</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />
Normal
</span>
<span className="font-bold text-emerald-600">{normal}</span>
</div>
<div className="border-t border-gray-200 mt-1 pt-1 flex items-center justify-between">
<span className="text-gray-500">Prevalensi</span>
<span className="font-bold">{pct}%</span>
</div>
</div>
</div>
)
}
return null
}
export function StuntingChart({ data, availableYears }: Props) {
const [selectedYear, setSelectedYear] = useState<number>(availableYears[0] ?? 2026)
const [chartType, setChartType] = useState<'bar' | 'area'>('bar')
const [selectedPosyandu, setSelectedPosyandu] = useState<string>('all')
// Unique posyandu list
const posyanduList = useMemo(() => {
return Array.from(new Set(data.map(d => d.nama_posyandu).filter(Boolean))).sort()
}, [data])
// Active data after posyandu filter
const filteredData = useMemo(() => {
if (selectedPosyandu === 'all') return data
return data.filter(d => d.nama_posyandu === selectedPosyandu)
}, [data, selectedPosyandu])
// Process data for the selected year into 12 months
const chartData = useMemo(() => {
return MONTHS.map((month, idx) => {
const monthNum = idx + 1
const filtered = filteredData.filter(d => {
if (!d.tanggal_upload) return false
const date = new Date(d.tanggal_upload)
return date.getFullYear() === selectedYear && date.getMonth() + 1 === monthNum
})
const stunting = filtered.filter(d => d.status_stunting === true).length
const normal = filtered.filter(d => d.status_stunting === false).length
const total = stunting + normal
const prevalensi = total > 0 ? parseFloat(((stunting / total) * 100).toFixed(1)) : 0
return { month, stunting, normal, total, prevalensi }
})
}, [filteredData, selectedYear])
// Summary stats for selected year
const totalStunting = chartData.reduce((s, d) => s + d.stunting, 0)
const totalNormal = chartData.reduce((s, d) => s + d.normal, 0)
const totalAll = totalStunting + totalNormal
const prevalensiTotal = totalAll > 0 ? ((totalStunting / totalAll) * 100).toFixed(1) : '0.0'
// Trend: compare current year vs previous year prevalensi
const prevYearTotal = filteredData.filter(d => {
if (!d.tanggal_upload) return false
return new Date(d.tanggal_upload).getFullYear() === selectedYear - 1
})
const prevStunting = prevYearTotal.filter(d => d.status_stunting === true).length
const prevAll = prevYearTotal.length
const hasPrevData = prevAll > 0
const prevPrevalensi = hasPrevData ? (prevStunting / prevAll) * 100 : null
const currPrevalensiNum = totalAll > 0 ? (totalStunting / totalAll) * 100 : null
const trend: 'up' | 'down' | 'stable' | null =
hasPrevData && currPrevalensiNum !== null
? currPrevalensiNum > prevPrevalensi! ? 'up'
: currPrevalensiNum < prevPrevalensi! ? 'down'
: 'stable'
: null
return (
<div className="flex flex-col gap-6">
{/* Dynamic Card Title */}
<div className="flex items-center gap-4 mb-2 pb-6 border-b border-gray-100">
<div className="w-14 h-14 rounded-full bg-blue-50 border-2 border-blue-200 flex items-center justify-center text-blue-600 flex-shrink-0">
<TrendingUp className="w-7 h-7" />
</div>
<div>
<h2 className="text-2xl font-bold">
{selectedPosyandu === 'all'
? 'Data Tren Stunting Semua Posyandu'
: `Data Tren Stunting — ${selectedPosyandu}`}
</h2>
<p className="text-sm text-gray-500">
{selectedPosyandu === 'all'
? 'Menampilkan data dari semua posyandu · Pilih posyandu untuk melihat per lokasi'
: `Filter aktif: ${selectedPosyandu} · Pilih "Semua Posyandu" untuk melihat keseluruhan`}
</p>
</div>
</div>
{/* Controls Row */}
<div className="flex flex-wrap items-center gap-4 justify-between">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Year Filter */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-500">Periode:</span>
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="border-2 border-black rounded-lg px-3 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
{/* Posyandu Filter */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-500">Posyandu:</span>
<select
value={selectedPosyandu}
onChange={e => setSelectedPosyandu(e.target.value)}
className="border-2 border-black rounded-lg px-3 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer max-w-[200px] truncate"
>
<option value="all">Semua Posyandu</option>
{posyanduList.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
</div>
{/* Chart Type Toggle */}
<div className="flex gap-1 bg-gray-100 p-1 rounded-lg border border-gray-200">
<button
onClick={() => setChartType('bar')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${chartType === 'bar' ? 'bg-black text-white' : 'text-gray-600 hover:text-black'}`}
>
<BarChart2 className="w-3.5 h-3.5" /> Bar
</button>
<button
onClick={() => setChartType('area')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${chartType === 'area' ? 'bg-black text-white' : 'text-gray-600 hover:text-black'}`}
>
<Activity className="w-3.5 h-3.5" /> Area
</button>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Total Data */}
<div className="border-2 border-gray-200 rounded-xl p-4 bg-gray-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">Total Data</p>
<p className="text-3xl font-black">{totalAll}</p>
<p className="text-xs text-gray-500 mt-1">pemeriksaan</p>
</div>
{/* Stunting */}
<div className="border-2 border-red-200 rounded-xl p-4 bg-red-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-red-400 mb-1">Stunting</p>
<p className="text-3xl font-black text-red-600">{totalStunting}</p>
<p className="text-xs text-red-400 mt-1">kasus</p>
</div>
{/* Normal */}
<div className="border-2 border-emerald-200 rounded-xl p-4 bg-emerald-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-emerald-400 mb-1">Normal</p>
<p className="text-3xl font-black text-emerald-600">{totalNormal}</p>
<p className="text-xs text-emerald-400 mt-1">anak</p>
</div>
{/* Prevalensi */}
<div className="border-2 border-amber-200 rounded-xl p-4 bg-amber-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-1">Prevalensi</p>
<p className="text-3xl font-black text-amber-600">{prevalensiTotal}%</p>
{trend !== null && (
<div className="flex items-center gap-1 mt-1">
{trend === 'up' && <><TrendingUp className="w-3 h-3 text-red-500" /><span className="text-xs text-red-500">Naik vs {selectedYear - 1}</span></>}
{trend === 'down' && <><TrendingDown className="w-3 h-3 text-emerald-500" /><span className="text-xs text-emerald-500">Turun vs {selectedYear - 1}</span></>}
{trend === 'stable' && <><Minus className="w-3 h-3 text-gray-400" /><span className="text-xs text-gray-400">Sama vs {selectedYear - 1}</span></>}
</div>
)}
</div>
</div>
{/* Chart */}
<div className="border-2 border-gray-100 rounded-xl p-4 bg-white">
<p className="text-sm font-bold mb-1">Tren Stunting Semua Posyandu {selectedYear}</p>
<p className="text-xs text-gray-400 mb-4">Berdasarkan tanggal upload data</p>
{totalAll === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-300">
<TrendingUp className="w-16 h-16 opacity-30" />
<p className="font-semibold text-gray-400">Tidak ada data untuk tahun {selectedYear}</p>
</div>
) : (
<ResponsiveContainer width="100%" height={320}>
{chartType === 'bar' ? (
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }} barCategoryGap="30%">
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: 11, fontWeight: 600, paddingTop: 12 }}
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
/>
<Bar dataKey="stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
<Bar dataKey="normal" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="stuntingGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.25} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="normalGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: 11, fontWeight: 600, paddingTop: 12 }}
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
/>
<Area type="monotone" dataKey="normal" stroke="#10b981" strokeWidth={2.5} fill="url(#normalGrad)" dot={{ r: 4, fill: '#10b981' }} />
<Area type="monotone" dataKey="stunting" stroke="#ef4444" strokeWidth={2.5} fill="url(#stuntingGrad)" dot={{ r: 4, fill: '#ef4444' }} />
</AreaChart>
)}
</ResponsiveContainer>
)}
</div>
{/* Prevalensi (%) Line Chart */}
<div className="border-2 border-amber-100 rounded-xl p-4 bg-amber-50/30">
<div className="flex items-center gap-2 mb-1">
<Percent className="w-4 h-4 text-amber-600" />
<p className="text-sm font-bold text-amber-800">Prevalensi Stunting Per Bulan (%) {selectedYear}</p>
</div>
<p className="text-xs text-amber-500 mb-4">Persentase kasus stunting dari total pemeriksaan per bulan</p>
{totalAll === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-300">
<p className="text-sm">Tidak ada data untuk ditampilkan</p>
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 8, right: 16, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="prevalensiGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.15} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#fde68a" />
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis
tick={{ fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
domain={[0, 100]}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const val = payload[0]?.value as number
return (
<div className="bg-white border-2 border-amber-400 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
<p className="font-bold text-black mb-1">{label}</p>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 inline-block" />
<span>Prevalensi:</span>
<span className={`font-bold ${val >= 20 ? 'text-red-600' : 'text-emerald-600'}`}>{val}%</span>
</div>
<p className="text-[10px] text-gray-400 mt-1">
{val >= 20 ? '⚠️ Di atas ambang batas (20%)' : '✓ Di bawah ambang batas (20%)'}
</p>
</div>
)
}
return null
}}
/>
{/* Reference line 20% */}
<Line
type="monotone"
dataKey="prevalensi"
stroke="#f59e0b"
strokeWidth={3}
dot={({ cx, cy, payload }) => (
<circle
key={`dot-${payload.month}`}
cx={cx} cy={cy} r={5}
fill={payload.prevalensi >= 20 ? '#ef4444' : '#10b981'}
stroke="white"
strokeWidth={2}
/>
)}
activeDot={{ r: 7, stroke: '#f59e0b', strokeWidth: 2 }}
/>
</LineChart>
</ResponsiveContainer>
)}
<p className="text-[10px] text-amber-500 mt-2 text-center">
Merah = prevalensi 20% (tinggi) &nbsp;|&nbsp; Hijau = prevalensi &lt; 20% (aman)
</p>
</div>
<div className="border-2 border-gray-100 rounded-xl overflow-hidden">
<div className="grid grid-cols-5 bg-black text-white text-[10px] font-bold uppercase tracking-widest px-5 py-3">
<span>Bulan</span>
<span className="text-center">Total</span>
<span className="text-center text-red-300">Stunting</span>
<span className="text-center text-emerald-300">Normal</span>
<span className="text-center">Prevalensi</span>
</div>
{chartData.map((row, idx) => (
<div
key={row.month}
className={`grid grid-cols-5 items-center px-5 py-3 text-sm border-b border-gray-100 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/60'}`}
>
<span className="font-semibold">{row.month} {selectedYear}</span>
<span className="text-center font-bold">{row.total}</span>
<span className="text-center font-bold text-red-600">{row.stunting}</span>
<span className="text-center font-bold text-emerald-600">{row.normal}</span>
<span className="text-center">
{row.total > 0 ? (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-bold border ${row.prevalensi >= 20
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
}`}>
{row.prevalensi}%
</span>
) : (
<span className="text-gray-300 text-xs"></span>
)}
</span>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,80 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { StuntingChart } from './StuntingChart'
const START_YEAR = 2026
export default async function TrendStuntingPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'admin') redirect('/dashboard')
// Fetch all stunting data (only needed columns)
const { data: rawData, error } = await supabase
.from('hasil_stunting_balita')
.select('status_stunting, tanggal_upload, nama_posyandu')
.not('tanggal_upload', 'is', null)
.order('tanggal_upload', { ascending: true })
if (error) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold">
Gagal memuat data stunting.
</div>
)
}
// Build year list: always include from START_YEAR up to current year,
// plus any years found in real data beyond that range
const currentYear = new Date().getFullYear()
const dataYears = Array.from(
new Set((rawData ?? []).map(d => new Date(d.tanggal_upload).getFullYear()))
)
const allYears = Array.from(
new Set([
...Array.from({ length: Math.max(currentYear, ...dataYears) - START_YEAR + 1 }, (_, i) => START_YEAR + i),
...dataYears,
])
).filter(y => y >= START_YEAR).sort((a, b) => a - b)
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Trend Stunting Daerah</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">ANALISIS DATA SEMUA POSYANDU</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full">
{/* Page Card */}
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
{/* Chart Component (Client) */}
<StuntingChart
data={rawData ?? []}
availableYears={allYears}
/>
</div>
</main>
</div>
)
}

193
app/user-dashboard/page.tsx Normal file
View File

@ -0,0 +1,193 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { RealtimeClock } from '@/components/realtime-clock'
import { FeatureCard } from '@/components/feature-card'
import { Activity, User, Phone, MapPin, Baby, Calendar, UserCheck } from 'lucide-react'
import { Users, TrendingUp, ClipboardList, Building2 } from 'lucide-react' // Icons for feature cards
export default async function UserDashboardPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) {
redirect('/')
}
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'user') {
redirect('/dashboard')
}
// Fetch User Data from akun_balita
const { data: userResult, error } = await supabase
.from('akun_balita')
.select('*')
.eq('id', session.id)
.single()
if (error || !userResult) {
return <div>Error loading profile.</div>
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full border border-black flex items-center justify-center">
<Activity className="h-5 w-5 text-black" />
</div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">HealthPortal</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">USER DASHBOARD</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto">
{/* Main Single Frame */}
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
{/* Top Section - Welcome & Clock */}
<div className="p-8 border-b border-black flex flex-col md:flex-row justify-between items-start md:items-center">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2 mb-2">
<Activity className="h-6 w-6" />
Selamat Datang!
</h2>
<p className="text-gray-600">
Bunda <span className="font-bold text-black">{userResult.nama_orang_tua}</span>
</p>
</div>
<div className="mt-6 md:mt-0">
<RealtimeClock />
</div>
</div>
{/* Bottom Section - Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{/* Nama Orang Tua */}
<div className="p-6 border-b md:border-b-0 md:border-r border-black flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<User className="h-3 w-3" />
Nama Orang Tua
</div>
<div className="text-lg font-bold">{userResult.nama_orang_tua}</div>
</div>
{/* Alamat */}
<div className="p-6 border-b md:border-b-0 md:border-r border-black flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<MapPin className="h-3 w-3" />
Alamat
</div>
<div className="text-lg font-bold">{userResult.alamat || '-'}</div>
</div>
{/* No WhatsApp */}
<div className="p-6 border-b md:border-b-0 border-black flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<Phone className="h-3 w-3" />
No WhatsApp
</div>
<div className="text-lg font-bold">{userResult.no_whatsapp || userResult.no_telp || '-'}</div>
</div>
{/* Nama Anak (New Row in LG) */}
<div className="p-6 border-b md:border-b-0 md:border-r border-black border-t flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<Baby className="h-3 w-3" />
Nama Anak
</div>
<div className="text-lg font-bold">{userResult.nama_anak}</div>
</div>
{/* Jenis Kelamin Anak */}
<div className="p-6 border-b md:border-b-0 md:border-r border-black border-t flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<UserCheck className="h-3 w-3" />
Jenis Kelamin
</div>
<div className="text-lg font-bold">{userResult.jenis_kelamin || userResult.jenis_kelamin_anak || '-'}</div>
</div>
{/* Tanggal Lahir */}
<div className="p-6 border-black border-t flex flex-col gap-2">
<div className="flex items-center gap-2 text-gray-500 text-[10px] font-bold uppercase tracking-widest">
<Calendar className="h-3 w-3" />
Tanggal Lahir
</div>
<div className="text-lg font-bold">{userResult.tanggal_lahir || '-'}</div>
</div>
</div>
</div>
{/* Feature Menu Grid - Placeholder/Same as Admin for now as requested "Same Page" */}
{/* User usually doesn't manage account same way, but let's keep visual consistency.
However, "Input Data" doesn't make sense for user.
I will render a simplified list or maybe "Riwayat" cards.
But user said "halamannya sama kayak dashboard role 1". I will render the same cards but maybe just visual.
Actually, if I render buttons that don't work/link to admin pages, it's bad UX.
I'll render them but maybe change titles to "Riwayat..." if I can guess.
Refusing to guess: I will render the *layout* but maybe with generic or restricted options.
Wait, "halaman nya sama kayak dashboard role 1" might mean exact same features?
Role 1 is Admin. Role 2 is User.
If I give User "Kelola Data", they can't effectively do it (backend should block).
I will stick to the safer option: Render the same cards (visual) but maybe point to # or generic user pages.
Actually, better to show "Riwayat Pemeriksaan", "Kartu Menuju Sehat", etc.
BUT, I will follow the "same like dashboard role 1" instruction literally for the *Structure* but I'll try to keep the *Cards* if the user implies that.
The user specifically listed the fields for the top part.
I'll render the same cards. If they are admin features, so be it (they will likely fail or redirect if clicked).
Actually, I'll comment them out or replace with "Menu Pengguna" placeholders to avoid confusion?
No, "User Dashboard" implies user specific features.
I'll add 3 generic user cards to match the grid look.
*/}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
{/* 1. Trend & Statistik Stunting */}
<FeatureCard
title="Trend & Statistik Stunting"
description="Lihat data statistik dan trend stunting di wilayah anda."
icon={TrendingUp}
href="#"
color="blue"
/>
{/* 2. Perkembangan Balita */}
<FeatureCard
title="Perkembangan Balita"
description="Pantau grafik pertumbuhan dan perkembangan anak."
icon={Baby}
href="#"
color="green"
/>
{/* 3. Lokasi Posyandu */}
<FeatureCard
title="Lokasi Posyandu"
description="Temukan lokasi posyandu terdekat dan jadwal kegiatan."
icon={MapPin}
href="#"
color="orange"
/>
{/* 4. Artikel Stunting */}
<FeatureCard
title="Artikel Stunting"
description="Baca artikel dan informasi edukasi pencegahan stunting."
icon={ClipboardList}
href="#"
color="purple"
/>
</div>
</main>
</div>
)
}

614
package-lock.json generated
View File

@ -14,10 +14,13 @@
"@supabase/supabase-js": "^2.95.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
@ -236,6 +239,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -1514,6 +1526,42 @@
}
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1521,6 +1569,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz",
@ -1892,6 +1952,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1922,12 +2045,25 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@ -1948,6 +2084,19 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -2788,6 +2937,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -2935,6 +3093,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"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"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3013,6 +3191,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3028,6 +3218,15 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -3035,6 +3234,127 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -3114,6 +3434,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3180,6 +3506,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3400,6 +3736,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -3847,6 +4193,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3898,6 +4250,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -3908,6 +4271,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -4293,6 +4662,19 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@ -4312,6 +4694,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -4354,6 +4746,21 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -4878,6 +5285,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT",
"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"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -5648,6 +6072,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -5688,6 +6118,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -5799,6 +6236,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@ -5824,9 +6271,76 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -5850,6 +6364,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@ -5871,6 +6392,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -5923,6 +6450,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -6240,6 +6777,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -6439,6 +6986,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@ -6470,6 +7027,21 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -6806,6 +7378,46 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"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"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -15,10 +15,13 @@
"@supabase/supabase-js": "^2.95.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {