all features
This commit is contained in:
parent
a6d4670f06
commit
f6e65b5545
|
|
@ -73,3 +73,73 @@ export async function logout() {
|
||||||
cookieStore.delete('user_session')
|
cookieStore.delete('user_session')
|
||||||
redirect('/')
|
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.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 & 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ export default function ManajemenAkunPage() {
|
||||||
title="Kelola Akun Petugas"
|
title="Kelola Akun Petugas"
|
||||||
description="Kelola akun anda sebagai petugas. Ubah profil, password, dan informasi petugas lainnya."
|
description="Kelola akun anda sebagai petugas. Ubah profil, password, dan informasi petugas lainnya."
|
||||||
icon={UserCog}
|
icon={UserCog}
|
||||||
href="#"
|
href="/dashboard/manajemen-akun/petugas"
|
||||||
color="green"
|
color="green"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -46,7 +46,7 @@ export default function ManajemenAkunPage() {
|
||||||
title="Kelola Akun Pengguna"
|
title="Kelola Akun Pengguna"
|
||||||
description="Kelola data akun pengguna (Masyarakat). Reset password, pemblokiran, dan manajemen akses."
|
description="Kelola data akun pengguna (Masyarakat). Reset password, pemblokiran, dan manajemen akses."
|
||||||
icon={Users}
|
icon={Users}
|
||||||
href="#"
|
href="/dashboard/manajemen-akun/pengguna"
|
||||||
color="blue"
|
color="blue"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ export default async function DashboardPage() {
|
||||||
title="Trend Stunting Daerah"
|
title="Trend Stunting Daerah"
|
||||||
description="Analisis grafik dan data statistik stunting di wilayah kerja."
|
description="Analisis grafik dan data statistik stunting di wilayah kerja."
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
href="#"
|
href="/dashboard/trend-stunting"
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ export default async function DashboardPage() {
|
||||||
title="Kelola Data"
|
title="Kelola Data"
|
||||||
description="Input, ubah, dan validasi data posyandu secara terpusat."
|
description="Input, ubah, dan validasi data posyandu secara terpusat."
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
href="#"
|
href="/dashboard/kelola-data"
|
||||||
color="orange"
|
color="orange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export default async function DashboardPage() {
|
||||||
title="Manajemen Posyandu"
|
title="Manajemen Posyandu"
|
||||||
description="Pengaturan operasional dan administrasi posyandu."
|
description="Pengaturan operasional dan administrasi posyandu."
|
||||||
icon={Building2}
|
icon={Building2}
|
||||||
href="#"
|
href="/dashboard/manajemen-posyandu"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) | ● Hijau = prevalensi < 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,13 @@
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -236,6 +239,15 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -1521,6 +1569,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.95.3",
|
"version": "2.95.3",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz",
|
||||||
|
|
@ -1892,6 +1952,69 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -1922,12 +2045,25 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/phoenix": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.13",
|
"version": "19.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||||
|
|
@ -1948,6 +2084,19 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
|
@ -2788,6 +2937,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
|
|
@ -2935,6 +3093,26 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -3013,6 +3191,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -3028,6 +3218,15 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -3035,6 +3234,127 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -3180,6 +3506,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -3400,6 +3736,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -3847,6 +4193,12 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -3898,6 +4250,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
|
@ -3908,6 +4271,12 @@
|
||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -4293,6 +4662,19 @@
|
||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/iceberg-js": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
|
@ -4312,6 +4694,16 @@
|
||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -4354,6 +4746,21 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -4878,6 +5285,23 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"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"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -5688,6 +6118,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -5799,6 +6236,16 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
|
|
@ -5824,9 +6271,76 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -5850,6 +6364,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"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"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -5923,6 +6450,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
@ -6240,6 +6777,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
|
@ -6470,6 +7027,21 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -6806,6 +7378,46 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,13 @@
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue