diff --git a/app/actions.ts b/app/actions.ts index bfb7f7e..f4be0bf 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -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.' } + } +} diff --git a/app/dashboard/kelola-data/CetakInstanModal.tsx b/app/dashboard/kelola-data/CetakInstanModal.tsx new file mode 100644 index 0000000..4135d6b --- /dev/null +++ b/app/dashboard/kelola-data/CetakInstanModal.tsx @@ -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(null) + const [step, setStep] = useState('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(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 */} + + + {/* Backdrop + Modal */} + {open && ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +
+

Cetak Data Instan

+

+ Generate PDF semua balita per periode +

+
+
+ {step !== 'generating' && ( + + )} +
+ + {/* ── Steps: Config, Generating, Done, Error ── */} + {step === 'config' && ( +
+
+

Pilih Periode

+
+
+ + + +
+
+ + + +
+
+
+ +
+

Lokasi Penyimpanan

+ {supportsFileSys ? ( +
+ + {dirHandle &&
Folder dibuat: {folderName}
} +
+ ) : ( +
Browser tidak mendukung pemilihan direktori. PDF akan diunduh satu per satu.
+ )} +
+ + +
+ )} + + {step === 'generating' && ( +
+
+ +
+

Sedang mencetak PDF...

+

Harap tunggu, jangan tutup halaman ini.

+
+
+
+
+ {progress.current} / {progress.total} balita + {pct}% +
+
+
+
+
+ {progress.name && ( +
+

{progress.name}

+

Ibu: {progress.mama}

+
+ )} +
+
+

{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}

+
+
+

{estRemaining !== null ? `${String(Math.floor(estRemaining / 60)).padStart(2, '0')}:${String(estRemaining % 60).padStart(2, '0')}` : '--:--'}

+
+
+
+ )} + + {step === 'done' && ( +
+ +

Selesai! {progress.total} file dicetak.

+ +
+ )} + + {step === 'error' && ( +
+

Gagal

+

{errorMsg}

+ +
+ )} +
+
+ )} + + {/* ─── HIDDEN PDF TEMPLATE (Rich HTML) ─── */} + {activePrintData && ( +
+
+ {/* Header */} +
+
+
Sistem Informasi Posyandu
+
Laporan Pemeriksaan Balita
+
+
+
Tanggal Cetak
+
{tanggalCetak}
+
Tgl Pemeriksaan
+
{formatTgl(activePrintData.row.tanggal_upload)}
+
+
+ + {/* Identitas */} +
+
Identitas
+
+ {[ + ['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) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {/* Charts */} +
+
Grafik Perkembangan (5 Bulan)
+
+
+
📏 Tinggi Badan (cm)
+ + + + + + + + + + + + + + +
+
+
⚖️ Berat Badan (kg)
+ + + + + + + + + + + + + + +
+
+
+ + {/* Table */} +
+
Data Pemeriksaan
+ + + + {['Tinggi', 'Berat', 'Status', 'Posyandu', 'Tgl Upload'].map(h => ( + + ))} + + + + + + + + + + + +
{h}
{activePrintData.row.tinggi_badan} cm{activePrintData.row.berat_badan} kg + + {isStunting ? 'Stunting' : 'Normal'} + + {activePrintData.row.nama_posyandu}{formatTgl(activePrintData.row.tanggal_upload)}
+ {activePrintData.row.pesan_ai && ( +
+
PESAN AI
+
{activePrintData.row.pesan_ai}
+
+ )} +
+ + {/* Signatures */} +
+
+
Mengetahui,
+
Supervisor
+
+
+
Petugas Posyandu,
+
Nama & Tanda Tangan
+
+
+
+
+ )} + + ) +} diff --git a/app/dashboard/kelola-data/KelolaDataTable.tsx b/app/dashboard/kelola-data/KelolaDataTable.tsx new file mode 100644 index 0000000..a823e3b --- /dev/null +++ b/app/dashboard/kelola-data/KelolaDataTable.tsx @@ -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 */} +
+
+ + 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" + /> +
+ + {filtered.length} dari {data.length} data + +
+ + {/* Table */} +
+ {/* Header */} +
+ # + Nama Ibu / Orang Tua + Nama Anak + Aksi +
+ + {/* Rows */} + {filtered.length === 0 ? ( +
+ +

Tidak ada data ditemukan

+
+ ) : ( + filtered.map((row, idx) => ( +
+ {/* Index */} +
+ + {idx + 1} + +
+ + {/* Nama Ibu */} +
+
+ {row.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'} +
+ {row.nama_orang_tua ?? '-'} +
+ + {/* Nama Anak */} +
+ + {row.nama_anak ?? '-'} +
+ + {/* Aksi — navigate ke halaman detail */} +
+ + + Review + +
+
+ )) + )} +
+ + ) +} diff --git a/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx new file mode 100644 index 0000000..db7f343 --- /dev/null +++ b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx @@ -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(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 ─── */} +
+
+ {/* ── Header ── */} +
+
+
+ Sistem Informasi Posyandu +
+
+ Laporan Pemeriksaan Balita +
+
+
+
Tanggal Cetak
+
{tanggalCetak}
+
Tgl Pemeriksaan
+
{tanggalUpload}
+
+
+ + {/* ── Identitas ── */} +
+
+ Identitas +
+
+ {[ + ['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 ? ( +
+
{label}
+
{value}
+
+ ) :
+ ))} +
+
+ + {/* ── Charts ── */} +
+
+ Grafik Perkembangan Balita (5 Bulan Terakhir) +
+
+ {/* Tinggi */} +
+
📏 Tinggi Badan (cm)
+ + + + + + + + + + + + + + +
+ {/* Berat */} +
+
⚖️ Berat Badan (kg)
+ + + + + + + + + + + + + + +
+
+
+ + {/* ── Data Pemeriksaan ── */} +
+
+ Data Pemeriksaan +
+ + + + {['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => ( + + ))} + + + + + + + + + + + +
{h}
{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''} + + {isStunting ? '⚠ Stunting' : '✓ Normal'} + + {row.nama_posyandu ?? '-'}{tanggalUpload}
+ + {/* Pesan AI */} + {row.pesan_ai && ( +
+
+ Pesan Kecerdasan Buatan (AI) +
+
{row.pesan_ai}
+
+ )} +
+ + {/* ── Footer / Tanda Tangan ── */} +
+
+
Mengetahui,
+
+
Kepala Puskesmas / Supervisor
+
+
+
+
Petugas Posyandu,
+
+
Nama & Tanda Tangan
+
+
+
+ + {/* ── Doc footer ── */} +
+ Dicetak oleh Sistem Informasi Posyandu + Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem +
+
+
+ + {/* ─── Visible Button ─── */} + + + ) +} diff --git a/app/dashboard/kelola-data/[id]/HasilStuntingTable.tsx b/app/dashboard/kelola-data/[id]/HasilStuntingTable.tsx new file mode 100644 index 0000000..03e10e6 --- /dev/null +++ b/app/dashboard/kelola-data/[id]/HasilStuntingTable.tsx @@ -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(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 ( +
+ {/* Section Header + Filter */} +
+
+ +

Riwayat Hasil Pengukuran

+
+
+ Periode: +
+ + +
+ {filtered.length} data +
+
+ + {/* Table */} +
+ {/* Header */} +
+ # + Tinggi + Berat + Status + Pesan AI + Posyandu + Tgl Upload + Aksi +
+ + {filtered.length === 0 ? ( +
+ +

Tidak ada data untuk tahun {selectedYear}

+
+ ) : ( + filtered.map((row, idx) => { + const isStunting = row.status_stunting === true + + return ( +
+ {/* Index */} +
+ {idx + 1} +
+ + {/* Tinggi Badan */} +
+ {row.tinggi_badan ?? '-'} + {row.tinggi_badan && cm} +
+ + {/* Berat Badan */} +
+ {row.berat_badan ?? '-'} + {row.berat_badan && kg} +
+ + {/* Status Stunting */} +
+ {row.status_stunting === null ? ( + + ) : ( + + {isStunting ? '⚠ Stunting' : '✓ Normal'} + + )} +
+ + {/* Pesan AI — truncated */} +
+ {row.pesan_ai ? ( +

+ {row.pesan_ai.length > 80 + ? row.pesan_ai.slice(0, 80) + '..........' + : row.pesan_ai} +

+ ) : ( + Tidak ada pesan + )} +
+ + {/* Nama Posyandu */} +
+ {row.nama_posyandu ?? '-'} +
+ + {/* Tanggal Upload */} +
+ {formatDate(row.tanggal_upload)} +
+ + {/* Aksi: Cetak PDF */} +
+ +
+
+ ) + }) + )} +
+
+ ) +} diff --git a/app/dashboard/kelola-data/[id]/PerkembanganChart.tsx b/app/dashboard/kelola-data/[id]/PerkembanganChart.tsx new file mode 100644 index 0000000..bbd9d52 --- /dev/null +++ b/app/dashboard/kelola-data/[id]/PerkembanganChart.tsx @@ -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 ( +
+

{label}

+

+ {payload[0]?.value ?? '-'} {unit} +

+
+ ) +} + +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(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 ( +
+ {/* Section header + filter */} +
+
+
+
+
+
+

+ Grafik Perkembangan Balita +

+
+
+ Periode: +
+ + +
+
+
+ + {/* Charts Grid */} +
+ + {/* Tinggi Badan */} +
+
+
+ +
+
+

Tinggi Badan

+

Dalam satuan cm

+
+
+ {!hasData ? ( +
+ Tidak ada data untuk tahun {selectedYear} +
+ ) : ( + + + + + + + + + + + + } /> + + + + )} +
+ + {/* Berat Badan */} +
+
+
+ +
+
+

Berat Badan

+

Dalam satuan kg

+
+
+ {!hasData ? ( +
+ Tidak ada data untuk tahun {selectedYear} +
+ ) : ( + + + + + + + + + + + + } /> + + + + )} +
+ +
+
+ ) +} diff --git a/app/dashboard/kelola-data/[id]/page.tsx b/app/dashboard/kelola-data/[id]/page.tsx new file mode 100644 index 0000000..bb1f1b1 --- /dev/null +++ b/app/dashboard/kelola-data/[id]/page.tsx @@ -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 ( +
+
+ {icon} + {label} +
+

+ {value || Tidak ada data} +

+
+ ) +} + +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 ( +
+ Data tidak ditemukan. +
+ ) + } + + // 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 ( +
+ {/* Header */} +
+
+ +
+ +
+ Kembali ke Daftar + +
+
+

Detail Pengguna

+

REVIEW DATA

+
+
+ +
+ +
+
+ + {/* Card Hero */} +
+
+ {pengguna.nama_orang_tua?.charAt(0).toUpperCase() ?? '?'} +
+
+

{pengguna.nama_orang_tua}

+
+ + {pengguna.nama_anak ?? '-'} +
+
+ +
+ +
+ + {/* Section: Data Orang Tua */} +
+
+ +

Data Orang Tua

+
+
+
+ } label="Nama Ibu / Orang Tua" value={pengguna.nama_orang_tua} /> +
+
+ } label="Alamat" value={pengguna.alamat} /> +
+
+ } label="No. WhatsApp" value={pengguna.no_whatsapp} /> +
+
+
+ +
+ + {/* Section: Data Anak */} +
+
+ +

Data Anak

+
+
+
+ } label="Nama Anak" value={pengguna.nama_anak} /> +
+ : } + label="Jenis Kelamin" + value={pengguna.jenis_kelamin} + accent={isLaki ? 'blue' : 'pink'} + /> + } + label="Tanggal Lahir" + value={formatDate(pengguna.tanggal_lahir)} + /> +
+
+ + {/* Separator: Perkembangan */} +
+
+
+
+ Grafik Perkembangan +
+
+
+
+ + {/* Chart Perkembangan Tinggi & Berat */} + + + {/* Professional Separator */} +
+
+
+
+ Riwayat Pengukuran +
+
+
+
+ + {/* Tabel Riwayat Hasil Stunting */} + + +
+
+
+
+ ) +} diff --git a/app/dashboard/kelola-data/page.tsx b/app/dashboard/kelola-data/page.tsx new file mode 100644 index 0000000..956219c --- /dev/null +++ b/app/dashboard/kelola-data/page.tsx @@ -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 ( +
+ Gagal memuat data. +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +
+ +
+ Kembali + +
+
+

Kelola Data

+

DATA PENGGUNA TERDAFTAR

+
+
+ +
+ +
+
+ + {/* Card Header */} +
+
+ +
+
+

Daftar Data Pengguna

+

+ Review informasi lengkap data akun pengguna yang terdaftar +

+
+
+ +
+ {data?.length ?? 0} + Total
Pengguna
+
+
+
+ + +
+
+
+ ) +} diff --git a/app/dashboard/manajemen-akun/page.tsx b/app/dashboard/manajemen-akun/page.tsx index 6d22ee8..14a98af 100644 --- a/app/dashboard/manajemen-akun/page.tsx +++ b/app/dashboard/manajemen-akun/page.tsx @@ -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" /> diff --git a/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx b/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx new file mode 100644 index 0000000..d0b0831 --- /dev/null +++ b/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx @@ -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 ( +
+ {type === 'success' + ? + : + } + {message} + +
+ ) +} + +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 && setToast(null)} />} + + +
+ + + {/* Section: Data Orang Tua */} +
+

Data Orang Tua

+
+ +
+ {/* Nama Orang Tua */} +
+ + +
+ + {/* No WhatsApp */} +
+ + +
+
+ + {/* Alamat */} +
+ +