Compare commits
10 Commits
c7960f598c
...
653ea8afe6
| Author | SHA1 | Date |
|---|---|---|
|
|
653ea8afe6 | |
|
|
6dec5a0470 | |
|
|
b7d51908d6 | |
|
|
4d4c6ca2a6 | |
|
|
f92bfbcb4f | |
|
|
d74be80085 | |
|
|
8fc5257458 | |
|
|
96ecb499b9 | |
|
|
bcd1f06635 | |
|
|
85af7f5676 |
117
app/actions.ts
117
app/actions.ts
|
|
@ -3,14 +3,16 @@
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
export async function login(prevState: any, formData: FormData) {
|
export async function login(prevState: any, formData: FormData) {
|
||||||
const username = formData.get('username') as string
|
const username = formData.get('username') as string
|
||||||
const password = formData.get('password') as string
|
const password = formData.get('password') as string
|
||||||
|
const kodeUnikNik = formData.get('kode_unik_nik') as string
|
||||||
const remember = formData.get('remember') === 'on'
|
const remember = formData.get('remember') === 'on'
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || !kodeUnikNik) {
|
||||||
return { message: 'Username and password are required' }
|
return { message: 'Username, password, dan Kode Unik / NIK wajib diisi' }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -23,6 +25,10 @@ export async function login(prevState: any, formData: FormData) {
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (petugas) {
|
if (petugas) {
|
||||||
|
if (petugas.kode_unik !== kodeUnikNik) {
|
||||||
|
return { message: 'Kode Unik tidak valid' }
|
||||||
|
}
|
||||||
|
|
||||||
// Set session/cookie for Admin
|
// Set session/cookie for Admin
|
||||||
// In a real app, use a secure session library. For this demo, simple cookies.
|
// In a real app, use a secure session library. For this demo, simple cookies.
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
@ -45,6 +51,10 @@ export async function login(prevState: any, formData: FormData) {
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
if (user.NIK !== kodeUnikNik) {
|
||||||
|
return { message: 'NIK tidak valid' }
|
||||||
|
}
|
||||||
|
|
||||||
// Set session/cookie for User
|
// Set session/cookie for User
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
cookieStore.set('user_session', JSON.stringify({
|
cookieStore.set('user_session', JSON.stringify({
|
||||||
|
|
@ -57,7 +67,7 @@ export async function login(prevState: any, formData: FormData) {
|
||||||
redirect('/user-dashboard') // Redirect to user dashboard
|
redirect('/user-dashboard') // Redirect to user dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: 'Invalid username or password' }
|
return { message: 'Username atau password salah' }
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === 'NEXT_REDIRECT') {
|
if (error.message === 'NEXT_REDIRECT') {
|
||||||
|
|
@ -143,3 +153,104 @@ export async function updateAkunBalita(prevState: any, formData: FormData) {
|
||||||
return { success: false, message: 'Gagal memperbarui data pengguna. Coba lagi.' }
|
return { success: false, message: 'Gagal memperbarui data pengguna. Coba lagi.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAkunBalita(id: string) {
|
||||||
|
if (!id) return { success: false, message: 'ID Akun wajib diisi' }
|
||||||
|
try {
|
||||||
|
// Hapus riwayat pengukuran stunting terkait dulu
|
||||||
|
const { error: errHasil } = await supabase
|
||||||
|
.from('hasil_stunting_balita')
|
||||||
|
.delete()
|
||||||
|
.eq('id_balita', id)
|
||||||
|
if (errHasil) throw errHasil
|
||||||
|
|
||||||
|
// Hapus akun balita
|
||||||
|
const { error: errAkun } = await supabase
|
||||||
|
.from('akun_balita')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
if (errAkun) throw errAkun
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/manajemen-akun/pengguna')
|
||||||
|
revalidatePath('/dashboard/kelola-data')
|
||||||
|
return { success: true, message: 'Akun berhasil dihapus!' }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting akun:', error)
|
||||||
|
return { success: false, message: error.message || 'Gagal menghapus akun. Coba lagi.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHasilStunting(prevState: any, formData: FormData) {
|
||||||
|
const id = formData.get('id') as string
|
||||||
|
const tinggi_badan = formData.get('tinggi_badan') ? Number(formData.get('tinggi_badan')) : null
|
||||||
|
const berat_badan = formData.get('berat_badan') ? Number(formData.get('berat_badan')) : null
|
||||||
|
const z_score = formData.get('z_score') ? Number(formData.get('z_score')) : null
|
||||||
|
const status_stunting = formData.get('status_stunting') === 'true'
|
||||||
|
const pesan_ai = formData.get('pesan_ai') as string
|
||||||
|
const tanggal_upload = formData.get('tanggal_upload') as string
|
||||||
|
const nama_posyandu = formData.get('nama_posyandu') as string
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return { success: false, message: 'ID Pengukuran tidak valid.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('hasil_stunting_balita')
|
||||||
|
.update({
|
||||||
|
tinggi_badan,
|
||||||
|
berat_badan,
|
||||||
|
z_score,
|
||||||
|
status_stunting,
|
||||||
|
pesan_ai,
|
||||||
|
tanggal_upload: tanggal_upload || null,
|
||||||
|
nama_posyandu: nama_posyandu || null
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
.select('id_balita')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
if (data?.id_balita) {
|
||||||
|
revalidatePath(`/dashboard/kelola-data/${data.id_balita}`)
|
||||||
|
}
|
||||||
|
revalidatePath('/dashboard/kelola-data')
|
||||||
|
|
||||||
|
return { success: true, message: 'Data pengukuran berhasil diperbarui!' }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating stunting record:', error)
|
||||||
|
return { success: false, message: error.message || 'Gagal memperbarui data pengukuran.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHasilStunting(id: number) {
|
||||||
|
if (!id) return { success: false, message: 'ID Pengukuran tidak valid.' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ambil id_balita untuk revalidasi path
|
||||||
|
const { data: record } = await supabase
|
||||||
|
.from('hasil_stunting_balita')
|
||||||
|
.select('id_balita')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('hasil_stunting_balita')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
if (record?.id_balita) {
|
||||||
|
revalidatePath(`/dashboard/kelola-data/${record.id_balita}`)
|
||||||
|
}
|
||||||
|
revalidatePath('/dashboard/kelola-data')
|
||||||
|
|
||||||
|
return { success: true, message: 'Data pengukuran berhasil dihapus!' }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting stunting record:', error)
|
||||||
|
return { success: false, message: error.message || 'Gagal menghapus data pengukuran.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface HasilItem {
|
||||||
id_balita: number
|
id_balita: number
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
status_stunting: boolean | null
|
status_stunting: boolean | null
|
||||||
pesan_ai: string | null
|
pesan_ai: string | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
|
|
@ -29,6 +30,8 @@ interface PenggunaData {
|
||||||
nama_anak: string
|
nama_anak: string
|
||||||
jenis_kelamin: string | null
|
jenis_kelamin: string | null
|
||||||
tanggal_lahir: string | null
|
tanggal_lahir: string | null
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
|
|
@ -72,7 +75,12 @@ function build5MonthData(allData: HasilItem[], rowDate: Date) {
|
||||||
return id.getFullYear() === slot.year && id.getMonth() + 1 === slot.month && id <= rowDate
|
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' })
|
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 }
|
return {
|
||||||
|
label,
|
||||||
|
tinggi: match?.tinggi_badan ?? null,
|
||||||
|
berat: match?.berat_badan ?? null,
|
||||||
|
zscore: match?.z_score ?? null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +99,7 @@ export function CetakInstanModal() {
|
||||||
pengguna: PenggunaData
|
pengguna: PenggunaData
|
||||||
row: HasilItem
|
row: HasilItem
|
||||||
allHasil: HasilItem[]
|
allHasil: HasilItem[]
|
||||||
|
idBerkas: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const templateRef = useRef<HTMLDivElement>(null)
|
const templateRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -168,6 +177,8 @@ export function CetakInstanModal() {
|
||||||
folderHandle = await dirHandle.getDirectoryHandle(folderName, { create: true })
|
folderHandle = await dirHandle.getDirectoryHandle(folderName, { create: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentIdBerkas = Date.now()
|
||||||
|
|
||||||
// 3. Generation Loop
|
// 3. Generation Loop
|
||||||
for (let i = 0; i < targets.length; i++) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
const b = targets[i] as PenggunaData
|
const b = targets[i] as PenggunaData
|
||||||
|
|
@ -182,10 +193,21 @@ export function CetakInstanModal() {
|
||||||
|
|
||||||
setProgress({ current: i + 1, total: targets.length, name: b.nama_anak, mama: b.nama_orang_tua })
|
setProgress({ current: i + 1, total: targets.length, name: b.nama_anak, mama: b.nama_orang_tua })
|
||||||
|
|
||||||
|
// --- Save to cetak_balita ---
|
||||||
|
const { error: dbError } = await supabase.from('cetak_balita').insert({
|
||||||
|
id_berkas: currentIdBerkas,
|
||||||
|
nama_balita: b.nama_anak,
|
||||||
|
tanggal_cetak: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
throw new Error(dbError.message)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Update template and wait for render ---
|
// --- Update template and wait for render ---
|
||||||
setActivePrintData({ pengguna: b, row: rowForMonth, allHasil: balitaHasil })
|
setActivePrintData({ pengguna: b, row: rowForMonth, allHasil: balitaHasil, idBerkas: currentIdBerkas })
|
||||||
// Give React and Recharts some time to finish rendering the hidden template
|
// 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
|
await new Promise(r => setTimeout(r, 1000)) // 1s buffer for stable DOM & Recharts
|
||||||
|
|
||||||
if (!templateRef.current) continue
|
if (!templateRef.current) continue
|
||||||
|
|
||||||
|
|
@ -198,6 +220,13 @@ export function CetakInstanModal() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/png')
|
const imgData = canvas.toDataURL('image/png')
|
||||||
|
|
||||||
|
// Safety check: ensure imgData is a valid Data URI
|
||||||
|
if (!imgData || !imgData.startsWith('data:image/')) {
|
||||||
|
console.error('Invalid image data generated for', b.nama_anak)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const pdf = new jsPDF('p', 'mm', 'a4')
|
const pdf = new jsPDF('p', 'mm', 'a4')
|
||||||
const pageW = pdf.internal.pageSize.getWidth()
|
const pageW = pdf.internal.pageSize.getWidth()
|
||||||
const pageH = pdf.internal.pageSize.getHeight()
|
const pageH = pdf.internal.pageSize.getHeight()
|
||||||
|
|
@ -242,7 +271,7 @@ export function CetakInstanModal() {
|
||||||
|
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
setStep('done')
|
setStep('done')
|
||||||
await showSwal.success('Selesai!', `Berhasil mencetak ${progress.total} file PDF.`)
|
await showSwal.success('Selesai!', `Berhasil mencetak ${targets.length} file PDF.`)
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
|
|
@ -268,6 +297,8 @@ export function CetakInstanModal() {
|
||||||
|
|
||||||
const isStunting = activePrintData?.row.status_stunting === true
|
const isStunting = activePrintData?.row.status_stunting === true
|
||||||
const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
|
const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
const tanggalUpload = activePrintData ? formatTgl(activePrintData.row.tanggal_upload) : '-'
|
||||||
|
const tanggalLahir = activePrintData ? formatTgl(activePrintData.pengguna.tanggal_lahir) : '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -378,11 +409,9 @@ export function CetakInstanModal() {
|
||||||
<p className="text-xs text-gray-500">Ibu: {progress.mama}</p>
|
<p className="text-xs text-gray-500">Ibu: {progress.mama}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<div className="text-center px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl">
|
<div className="flex flex-col items-center justify-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>
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 mb-1">Estimasi Selesai</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>
|
<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>
|
</div>
|
||||||
|
|
@ -410,7 +439,7 @@ export function CetakInstanModal() {
|
||||||
|
|
||||||
{/* ─── HIDDEN PDF TEMPLATE (Rich HTML) ─── */}
|
{/* ─── HIDDEN PDF TEMPLATE (Rich HTML) ─── */}
|
||||||
{activePrintData && (
|
{activePrintData && (
|
||||||
<div style={{ position: 'fixed', top: '-9999px', left: '-9999px', zIndex: -1 }}>
|
<div style={{ position: 'fixed', left: 0, top: 0, opacity: 0, pointerEvents: 'none', zIndex: -100, width: 'fit-content' }}>
|
||||||
<div
|
<div
|
||||||
ref={templateRef}
|
ref={templateRef}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -418,127 +447,171 @@ export function CetakInstanModal() {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
padding: '48px 56px',
|
padding: '32px 48px',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ borderBottom: '3px solid #111', paddingBottom: 20, marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ borderBottom: '3px solid #111', paddingBottom: 16, marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>Sistem Informasi Posyandu</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 style={{ fontSize: 22, fontWeight: 900, letterSpacing: -0.5 }}>Laporan Pemeriksaan Balita</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 10, color: '#888' }}>Tanggal Cetak</div>
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 3 }}>Tanggal Cetak</div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalCetak}</div>
|
<div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalCetak}</div>
|
||||||
<div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Tgl Pemeriksaan</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 style={{ fontSize: 12, fontWeight: 700 }}>{tanggalUpload}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Identitas */}
|
{/* Identitas */}
|
||||||
<div style={{ marginBottom: 28 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Identitas</div>
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>Identitas</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 32px' }}>
|
||||||
{[
|
{[
|
||||||
['Nama Ibu / Orang Tua', activePrintData.pengguna.nama_orang_tua],
|
['Nama Ibu / Orang Tua', activePrintData.pengguna.nama_orang_tua],
|
||||||
['Nama Anak', activePrintData.pengguna.nama_anak],
|
['Nama Anak', activePrintData.pengguna.nama_anak],
|
||||||
['Alamat', activePrintData.pengguna.alamat ?? '-'],
|
['Alamat', activePrintData.pengguna.alamat ?? '-'],
|
||||||
['Jenis Kelamin', activePrintData.pengguna.jenis_kelamin ?? '-'],
|
['Jenis Kelamin', activePrintData.pengguna.jenis_kelamin ?? '-'],
|
||||||
['Tanggal Lahir', formatTgl(activePrintData.pengguna.tanggal_lahir)],
|
['ID Berkas', String(activePrintData.idBerkas)],
|
||||||
|
['Tanggal Lahir', tanggalLahir],
|
||||||
].map(([label, value], i) => (
|
].map(([label, value], i) => (
|
||||||
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 8 }}>
|
label ? (
|
||||||
<div style={{ fontSize: 9, color: '#888', fontWeight: 700, textTransform: 'uppercase' }}>{label}</div>
|
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 6 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
|
<div style={{ fontSize: 8, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>{label}</div>
|
||||||
</div>
|
<div style={{ fontSize: 12, fontWeight: 600 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
) : <div key={i} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div style={{ marginBottom: 28 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Grafik Perkembangan (5 Bulan)</div>
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>Grafik Perkembangan Balita (5 Bulan Terakhir)</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 12, padding: '14px 14px 4px', background: '#eff6ff' }}>
|
{/* Panjang */}
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#1d4ed8', marginBottom: 8 }}>📏 Tinggi Badan (cm)</div>
|
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff' }}>
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Panjang Badan (cm)</div>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="instanTinggi" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="pdfTinggiGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
|
||||||
<XAxis dataKey="label" fontSize={9} axisLine={false} tickLine={false} />
|
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
<YAxis fontSize={9} axisLine={false} tickLine={false} />
|
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
|
||||||
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#instanTinggi)" dot={{ r: 4, fill: '#3b82f6', stroke: 'white' }} />
|
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)" dot={{ r: 3, fill: '#3b82f6', stroke: 'white' }} connectNulls={false} isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 12, padding: '14px 14px 4px', background: '#f0fdf4' }}>
|
{/* Berat */}
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#059669', marginBottom: 8 }}>⚖️ Berat Badan (kg)</div>
|
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 10, padding: '12px 12px 2px', background: '#f0fdf4' }}>
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#059669', marginBottom: 6 }}>⚖️ Berat Badan (kg)</div>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="instanBerat" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="pdfBeratGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
|
||||||
<XAxis dataKey="label" fontSize={9} axisLine={false} tickLine={false} />
|
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
<YAxis fontSize={9} axisLine={false} tickLine={false} />
|
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
|
||||||
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#instanBerat)" dot={{ r: 4, fill: '#10b981', stroke: 'white' }} />
|
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)" dot={{ r: 3, fill: '#10b981', stroke: 'white' }} connectNulls={false} isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Z-Score PDF Chart */}
|
||||||
|
<div style={{ marginTop: 16, border: '1.5px solid #f3e8ff', borderRadius: 10, padding: '12px 12px 2px', background: '#faf5ff' }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#9333ea', marginBottom: 6 }}>📈 Z-Score (SD)</div>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 16, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pdfZscoreGradAdmin" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9d5ff" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
|
||||||
|
<Area type="monotone" dataKey="zscore" stroke="#9333ea" strokeWidth={2} fill="url(#pdfZscoreGradAdmin)" dot={{ r: 3, fill: '#9333ea', stroke: 'white' }} connectNulls={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div style={{ marginBottom: 32 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>Data Pemeriksaan</div>
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>Data Pemeriksaan</div>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#111', color: '#fff' }}>
|
<tr style={{ background: '#111', color: '#fff' }}>
|
||||||
{['Tinggi', 'Berat', 'Status', 'Posyandu', 'Tgl Upload'].map(h => (
|
{['Panjang', 'Berat', 'Z-Score', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
|
||||||
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontSize: 10 }}>{h}</th>
|
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr style={{ background: '#f9fafb' }}>
|
<tr style={{ background: '#f9fafb' }}>
|
||||||
<td style={{ padding: '10px 12px', fontWeight: 700 }}>{activePrintData.row.tinggi_badan} cm</td>
|
<td style={{ padding: '10px', 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', fontWeight: 700 }}>{activePrintData.row.berat_badan ?? '-'} kg</td>
|
||||||
<td style={{ padding: '10px 12px' }}>
|
<td style={{ padding: '10px', fontWeight: 700 }}>{activePrintData.row.z_score ?? '-'} SD</td>
|
||||||
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 700, background: isStunting ? '#fee2e2' : '#dcfce7', color: isStunting ? '#991b1b' : '#166534' }}>
|
<td style={{ padding: '10px' }}>
|
||||||
{isStunting ? 'Stunting' : 'Normal'}
|
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: 20, fontSize: 10, fontWeight: 700, background: isStunting ? '#fef2f2' : '#f0fdf4', color: isStunting ? '#b91c1c' : '#15803d', border: `1px solid ${isStunting ? '#fecaca' : '#bbf7d0'}` }}>
|
||||||
|
{isStunting ? '⚠ Stunting' : '✓ Normal'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 12px' }}>{activePrintData.row.nama_posyandu}</td>
|
<td style={{ padding: '10px' }}>{activePrintData.row.nama_posyandu ?? '-'}</td>
|
||||||
<td style={{ padding: '10px 12px' }}>{formatTgl(activePrintData.row.tanggal_upload)}</td>
|
<td style={{ padding: '10px' }}>{tanggalUpload}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{activePrintData.row.pesan_ai && (
|
{activePrintData.row.pesan_ai && (
|
||||||
<div style={{ marginTop: 15, border: '1px solid #fde68a', borderRadius: 8, padding: '12px', background: '#fffbeb' }}>
|
<div style={{ marginTop: 8, border: '1.5px solid #fde68a', borderRadius: 8, padding: '10px 14px', background: '#fffbeb' }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 700, color: '#92400e', marginBottom: 4 }}>PESAN AI</div>
|
<div style={{ fontSize: 8, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 3 }}>Rekomendasi / Pesan AI</div>
|
||||||
<div style={{ fontSize: 11, color: '#78350f' }}>{activePrintData.row.pesan_ai}</div>
|
<div style={{ fontSize: 11, lineHeight: 1.5, color: '#78350f' }}>{activePrintData.row.pesan_ai}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signatures */}
|
{/* ── Portal Access Info Box ── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, marginTop: 40, paddingTop: 20, borderTop: '1px solid #eee' }}>
|
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
|
||||||
<div>
|
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 40 }}>Mengetahui,</div>
|
🌐 Akses Portal Online Orang Tua
|
||||||
<div style={{ borderTop: '1px solid #333', fontSize: 10, paddingTop: 4 }}>Supervisor</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ fontSize: 9, color: '#a16207', fontWeight: 500, fontStyle: 'italic', marginBottom: 10 }}>
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 40 }}>Petugas Posyandu,</div>
|
* Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda.
|
||||||
<div style={{ borderTop: '1px solid #333', fontSize: 10, paddingTop: 4 }}>Nama & Tanda Tangan</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Alamat Website (URL)</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>https://website-cloud-stunting.vercel.app/</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Username</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{activePrintData.pengguna.username || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Password</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{activePrintData.pengguna.password || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ marginTop: 16, paddingTop: 8, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', fontSize: 8, 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { showSwal } from '@/lib/swal'
|
import { showSwal } from '@/lib/swal'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
interface HasilItem {
|
interface HasilItem {
|
||||||
id: number
|
id: number
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
status_stunting: boolean | null
|
status_stunting: boolean | null
|
||||||
pesan_ai: string | null
|
pesan_ai: string | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
|
|
@ -68,6 +70,7 @@ function build5MonthData(allData: HasilItem[], rowDate: Date) {
|
||||||
label,
|
label,
|
||||||
tinggi: match?.tinggi_badan ?? null,
|
tinggi: match?.tinggi_badan ?? null,
|
||||||
berat: match?.berat_badan ?? null,
|
berat: match?.berat_badan ?? null,
|
||||||
|
zscore: match?.z_score ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +78,7 @@ function build5MonthData(allData: HasilItem[], rowDate: Date) {
|
||||||
export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
const templateRef = useRef<HTMLDivElement>(null)
|
const templateRef = useRef<HTMLDivElement>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [idBerkas, setIdBerkas] = useState<number | null>(null)
|
||||||
|
|
||||||
const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date()
|
const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date()
|
||||||
const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload])
|
const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload])
|
||||||
|
|
@ -88,6 +92,22 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
if (!templateRef.current || loading) return
|
if (!templateRef.current || loading) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const currentIdBerkas = Date.now()
|
||||||
|
setIdBerkas(currentIdBerkas)
|
||||||
|
|
||||||
|
const { error: dbError } = await supabase.from('cetak_balita').insert({
|
||||||
|
id_berkas: currentIdBerkas,
|
||||||
|
nama_balita: pengguna.nama_anak,
|
||||||
|
tanggal_cetak: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
throw new Error(dbError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunggu render React selesai (untuk menampilkan ID Berkas)
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
|
||||||
const { default: html2canvas } = await import('html2canvas')
|
const { default: html2canvas } = await import('html2canvas')
|
||||||
const { default: jsPDF } = await import('jspdf')
|
const { default: jsPDF } = await import('jspdf')
|
||||||
|
|
||||||
|
|
@ -174,7 +194,7 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
['Nama Anak', pengguna.nama_anak],
|
['Nama Anak', pengguna.nama_anak],
|
||||||
['Alamat', pengguna.alamat ?? '-'],
|
['Alamat', pengguna.alamat ?? '-'],
|
||||||
['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'],
|
['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'],
|
||||||
['', ''],
|
['ID Berkas', idBerkas ? String(idBerkas) : '-'],
|
||||||
['Tanggal Lahir', tanggalLahir],
|
['Tanggal Lahir', tanggalLahir],
|
||||||
].map(([label, value], i) => (
|
].map(([label, value], i) => (
|
||||||
label ? (
|
label ? (
|
||||||
|
|
@ -193,9 +213,9 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
Grafik Perkembangan Balita (5 Bulan Terakhir)
|
Grafik Perkembangan Balita (5 Bulan Terakhir)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
{/* Tinggi */}
|
{/* Panjang */}
|
||||||
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff' }}>
|
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff' }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Tinggi Badan (cm)</div>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Panjang Badan (cm)</div>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -210,6 +230,7 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)"
|
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)"
|
||||||
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
@ -231,11 +252,34 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)"
|
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)"
|
||||||
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Z-Score PDF Chart */}
|
||||||
|
<div style={{ marginTop: 16, border: '1.5px solid #f3e8ff', borderRadius: 10, padding: '12px 12px 2px', background: '#faf5ff', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#f3e8ff' }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#9333ea', marginBottom: 6 }}>📈 Z-Score (SD)</div>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 16, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pdfZscoreGradAdmin" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9d5ff" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
|
||||||
|
<Area type="monotone" dataKey="zscore" stroke="#9333ea" strokeWidth={2} fill="url(#pdfZscoreGradAdmin)"
|
||||||
|
dot={{ r: 3, fill: '#9333ea', stroke: 'white', strokeWidth: 2 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Data Pemeriksaan ── */}
|
{/* ── Data Pemeriksaan ── */}
|
||||||
|
|
@ -246,15 +290,16 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#111', color: '#fff' }}>
|
<tr style={{ background: '#111', color: '#fff' }}>
|
||||||
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
|
{['Panjang', 'Berat', 'Z-Score', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
|
||||||
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
|
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr style={{ background: '#f9fafb' }}>
|
<tr style={{ background: '#f9fafb' }}>
|
||||||
<td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td>
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} cm</td>
|
||||||
<td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td>
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} kg</td>
|
||||||
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.z_score ?? '-'} SD</td>
|
||||||
<td style={{ padding: '10px' }}>
|
<td style={{ padding: '10px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
|
@ -286,17 +331,6 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── WhatsApp Info Box ── */}
|
|
||||||
<div style={{ marginTop: 16, padding: '10px 14px', borderRadius: 10, backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
|
||||||
<div style={{ fontSize: 16 }}>📱</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 9, fontWeight: 800, color: '#166534', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>Layanan Informasi WhatsApp</div>
|
|
||||||
<div style={{ fontSize: 10, lineHeight: 1.5, color: '#14532d' }}>
|
|
||||||
Untuk orang tua yang tidak memiliki akun WhatsApp, yuk segera buat akun karena kami melayani layanan penyampaian informasi hasil stunting dengan menggunakan WhatsApp agar mendapatkan informasi lebih cepat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Portal Access Info Box ── */}
|
{/* ── Portal Access Info Box ── */}
|
||||||
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
|
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Activity, ChevronDown } from 'lucide-react'
|
import { Activity, ChevronDown, Edit, Trash2, X, Loader2 } from 'lucide-react'
|
||||||
import { CetakPDFButton } from './CetakPDFButton'
|
import { CetakPDFButton } from './CetakPDFButton'
|
||||||
|
import { deleteHasilStunting, updateHasilStunting } from '@/app/actions'
|
||||||
|
import { showSwal } from '@/lib/swal'
|
||||||
|
|
||||||
interface HasilStunting {
|
interface HasilStunting {
|
||||||
id: number
|
id: number
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
status_stunting: boolean | null
|
status_stunting: boolean | null
|
||||||
pesan_ai: string | null
|
pesan_ai: string | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
|
|
@ -25,11 +28,34 @@ interface Pengguna {
|
||||||
interface Props {
|
interface Props {
|
||||||
data: HasilStunting[]
|
data: HasilStunting[]
|
||||||
pengguna: Pengguna
|
pengguna: Pengguna
|
||||||
|
posyanduList: { nama_posyandu: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const START_YEAR = 2026
|
const START_YEAR = 2026
|
||||||
|
|
||||||
export function HasilStuntingTable({ data, pengguna }: Props) {
|
export function HasilStuntingTable({ data, pengguna, posyanduList }: Props) {
|
||||||
|
const [editRecord, setEditRecord] = useState<HasilStunting | null>(null)
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
const confirmed = await showSwal.confirm(
|
||||||
|
'Hapus Data Pengukuran?',
|
||||||
|
'Data pengukuran ini akan dihapus permanen dari riwayat balita!'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmed.isConfirmed) {
|
||||||
|
try {
|
||||||
|
const res = await deleteHasilStunting(id)
|
||||||
|
if (res.success) {
|
||||||
|
await showSwal.success('Berhasil!', res.message)
|
||||||
|
} else {
|
||||||
|
showSwal.error('Gagal!', res.message)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showSwal.error('Gagal!', err.message || 'Terjadi kesalahan.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const dataYears = Array.from(
|
const dataYears = Array.from(
|
||||||
|
|
@ -88,94 +114,307 @@ export function HasilStuntingTable({ data, pengguna }: Props) {
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
<div className="rounded-xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[1050px]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-[36px_88px_88px_88px_100px_1fr_108px_110px_140px] 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">Panjang</span>
|
||||||
|
<span className="text-center">Berat</span>
|
||||||
|
<span className="text-center">Z-Score</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_88px_100px_1fr_108px_110px_140px] 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>
|
||||||
|
|
||||||
|
{/* Panjang 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>
|
||||||
|
|
||||||
|
{/* Z-Score */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="font-bold">{row.z_score ?? '-'}</span>
|
||||||
|
{row.z_score !== null && <span className="text-xs text-gray-400 ml-0.5">SD</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, Edit, Hapus */}
|
||||||
|
<div className="flex justify-center items-center gap-1.5">
|
||||||
|
<CetakPDFButton row={row} allData={data} pengguna={pengguna} />
|
||||||
|
<button
|
||||||
|
onClick={() => setEditRecord(row)}
|
||||||
|
className="p-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-lg transition-all shadow-[1.5px_1.5px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[0.5px] hover:translate-y-[0.5px]"
|
||||||
|
title="Edit Data"
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
className="p-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all shadow-[1.5px_1.5px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[0.5px] hover:translate-y-[0.5px]"
|
||||||
|
title="Hapus Data"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editRecord && (
|
||||||
|
<EditHasilStuntingModal
|
||||||
|
isOpen={!!editRecord}
|
||||||
|
onClose={() => setEditRecord(null)}
|
||||||
|
record={editRecord}
|
||||||
|
posyanduList={posyanduList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
record: HasilStunting
|
||||||
|
posyanduList: { nama_posyandu: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditHasilStuntingModal({ isOpen, onClose, record, posyanduList }: EditModalProps) {
|
||||||
|
const [tinggi, setTinggi] = useState(record.tinggi_badan?.toString() || '')
|
||||||
|
const [berat, setBerat] = useState(record.berat_badan?.toString() || '')
|
||||||
|
const [zScore, setZScore] = useState(record.z_score?.toString() || '')
|
||||||
|
const [statusStunting, setStatusStunting] = useState(record.status_stunting === true ? 'true' : 'false')
|
||||||
|
const [pesanAi, setPesanAi] = useState(record.pesan_ai || '')
|
||||||
|
const [tanggalUpload, setTanggalUpload] = useState(record.tanggal_upload ? record.tanggal_upload.split('T')[0] : '')
|
||||||
|
const [namaPosyandu, setNamaPosyandu] = useState(record.nama_posyandu || '')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('id', record.id.toString())
|
||||||
|
formData.append('tinggi_badan', tinggi)
|
||||||
|
formData.append('berat_badan', berat)
|
||||||
|
formData.append('z_score', zScore)
|
||||||
|
formData.append('status_stunting', statusStunting)
|
||||||
|
formData.append('pesan_ai', pesanAi)
|
||||||
|
formData.append('tanggal_upload', tanggalUpload)
|
||||||
|
formData.append('nama_posyandu', namaPosyandu)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateHasilStunting(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
await showSwal.success('Berhasil!', res.message)
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
showSwal.error('Gagal!', res.message)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showSwal.error('Gagal!', err.message || 'Terjadi kesalahan.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center justify-between px-6 py-5 border-b-2 border-black bg-black text-white">
|
||||||
<span className="text-center text-gray-500">#</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-center">Tinggi</span>
|
<Activity className="w-5 h-5" />
|
||||||
<span className="text-center">Berat</span>
|
<h3 className="font-black text-lg">Edit Data Pengukuran</h3>
|
||||||
<span className="text-center">Status</span>
|
</div>
|
||||||
<span>Pesan AI</span>
|
<button onClick={onClose} className="p-1.5 rounded-full hover:bg-white/10 transition-colors">
|
||||||
<span className="text-center">Posyandu</span>
|
<X className="w-4 h-4" />
|
||||||
<span className="text-center">Tgl Upload</span>
|
</button>
|
||||||
<span className="text-center">Aksi</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{/* Form */}
|
||||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-gray-300">
|
<form onSubmit={handleSubmit} className="p-6 flex flex-col gap-4 max-h-[80vh] overflow-y-auto">
|
||||||
<Activity className="w-10 h-10 opacity-30" />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<p className="text-sm text-gray-400 font-semibold">Tidak ada data untuk tahun {selectedYear}</p>
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Panjang/Tinggi Badan (cm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={tinggi}
|
||||||
|
onChange={e => setTinggi(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Berat Badan (kg)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={berat}
|
||||||
|
onChange={e => setBerat(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
filtered.map((row, idx) => {
|
|
||||||
const isStunting = row.status_stunting === true
|
|
||||||
|
|
||||||
return (
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div
|
<div>
|
||||||
key={row.id}
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Z-Score (SD)</label>
|
||||||
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`}
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={zScore}
|
||||||
|
onChange={e => setZScore(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Status Stunting</label>
|
||||||
|
<select
|
||||||
|
value={statusStunting}
|
||||||
|
onChange={e => setStatusStunting(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none bg-white transition-colors"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
{/* Index */}
|
<option value="false">Normal</option>
|
||||||
<div className="flex justify-center">
|
<option value="true">Stunting</option>
|
||||||
<span className="text-xs text-gray-400 font-bold">{idx + 1}</span>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tinggi Badan */}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="text-center">
|
<div>
|
||||||
<span className="font-bold">{row.tinggi_badan ?? '-'}</span>
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Posyandu</label>
|
||||||
{row.tinggi_badan && <span className="text-xs text-gray-400 ml-0.5">cm</span>}
|
<select
|
||||||
</div>
|
value={namaPosyandu}
|
||||||
|
onChange={e => setNamaPosyandu(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none bg-white transition-colors"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Pilih Posyandu</option>
|
||||||
|
{posyanduList.map((p, idx) => (
|
||||||
|
<option key={idx} value={p.nama_posyandu}>
|
||||||
|
{p.nama_posyandu}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Tanggal Pengukuran</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tanggalUpload}
|
||||||
|
onChange={e => setTanggalUpload(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Berat Badan */}
|
<div>
|
||||||
<div className="text-center">
|
<label className="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1">Rekomendasi / Pesan AI</label>
|
||||||
<span className="font-bold">{row.berat_badan ?? '-'}</span>
|
<textarea
|
||||||
{row.berat_badan && <span className="text-xs text-gray-400 ml-0.5">kg</span>}
|
value={pesanAi}
|
||||||
</div>
|
onChange={e => setPesanAi(e.target.value)}
|
||||||
|
className="w-full border-2 border-gray-200 focus:border-black rounded-lg p-2.5 text-sm font-semibold focus:outline-none resize-none transition-colors"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Tulis pesan atau rekomendasi..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Stunting */}
|
{/* Actions */}
|
||||||
<div className="flex justify-center">
|
<div className="flex gap-3 pt-2">
|
||||||
{row.status_stunting === null ? (
|
<button
|
||||||
<span className="text-xs text-gray-300">—</span>
|
type="button"
|
||||||
) : (
|
onClick={onClose}
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${isStunting
|
className="flex-1 py-3 border-2 border-black font-bold text-sm rounded-xl hover:bg-gray-50 transition-colors"
|
||||||
? 'bg-red-50 border-red-200 text-red-700'
|
>
|
||||||
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
Batal
|
||||||
}`}>
|
</button>
|
||||||
{isStunting ? '⚠ Stunting' : '✓ Normal'}
|
<button
|
||||||
</span>
|
type="submit"
|
||||||
)}
|
disabled={loading}
|
||||||
</div>
|
className="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{/* Pesan AI — truncated */}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Simpan Perubahan'}
|
||||||
<div className="pr-2">
|
</button>
|
||||||
{row.pesan_ai ? (
|
</div>
|
||||||
<p className="text-xs text-gray-600 leading-relaxed">
|
</form>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ import {
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip,
|
XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { Ruler, Weight, ChevronDown } from 'lucide-react'
|
import { Ruler, Weight, ChevronDown, Activity } from 'lucide-react'
|
||||||
|
|
||||||
interface HasilItem {
|
interface HasilItem {
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ export function PerkembanganChart({ data }: Props) {
|
||||||
_date: new Date(d.tanggal_upload!).getTime(),
|
_date: new Date(d.tanggal_upload!).getTime(),
|
||||||
tinggi: d.tinggi_badan,
|
tinggi: d.tinggi_badan,
|
||||||
berat: d.berat_badan,
|
berat: d.berat_badan,
|
||||||
|
zscore: d.z_score,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a._date - b._date)
|
.sort((a, b) => a._date - b._date)
|
||||||
}, [data, selectedYear])
|
}, [data, selectedYear])
|
||||||
|
|
@ -105,14 +107,14 @@ export function PerkembanganChart({ data }: Props) {
|
||||||
{/* Charts Grid */}
|
{/* Charts Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
{/* Tinggi Badan */}
|
{/* Panjang Badan */}
|
||||||
<div className="rounded-xl border-2 border-blue-100 bg-blue-50/30 p-4">
|
<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="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">
|
<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" />
|
<Ruler className="w-4 h-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-blue-800">Tinggi Badan</p>
|
<p className="text-sm font-bold text-blue-800">Panjang Badan</p>
|
||||||
<p className="text-[10px] text-blue-400">Dalam satuan cm</p>
|
<p className="text-[10px] text-blue-400">Dalam satuan cm</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,6 +191,48 @@ export function PerkembanganChart({ data }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Z-Score Chart */}
|
||||||
|
<div className="rounded-xl border-2 border-purple-100 bg-purple-50/30 p-4 md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-purple-100 border border-purple-200 flex items-center justify-center">
|
||||||
|
<Activity className="w-4 h-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-purple-800">Z-Score (SD)</p>
|
||||||
|
<p className="text-[10px] text-purple-400">Standar Deviasi Pertumbuhan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="h-36 flex items-center justify-center text-gray-300 text-xs text-center border-2 border-dashed border-purple-100/50 rounded-lg">
|
||||||
|
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="zscoreGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#9333ea" stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor="#9333ea" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3e8ff" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
<Tooltip content={<MiniTooltip unit="SD" color="#9333ea" />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="zscore"
|
||||||
|
stroke="#9333ea"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="url(#zscoreGrad)"
|
||||||
|
dot={{ r: 4, fill: '#9333ea', stroke: 'white', strokeWidth: 2 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,16 @@ export default async function DetailPenggunaKelolaPage({ params }: Props) {
|
||||||
// Fetch hasil pengukuran stunting milik balita ini
|
// Fetch hasil pengukuran stunting milik balita ini
|
||||||
const { data: hasilData } = await supabase
|
const { data: hasilData } = await supabase
|
||||||
.from('hasil_stunting_balita')
|
.from('hasil_stunting_balita')
|
||||||
.select('id, tinggi_badan, berat_badan, status_stunting, pesan_ai, tanggal_upload, nama_posyandu')
|
.select('id, tinggi_badan, berat_badan, z_score, status_stunting, pesan_ai, tanggal_upload, nama_posyandu')
|
||||||
.eq('id_balita', pengguna.id)
|
.eq('id_balita', pengguna.id)
|
||||||
.order('tanggal_upload', { ascending: false })
|
.order('tanggal_upload', { ascending: false })
|
||||||
|
|
||||||
|
// Fetch list posyandu for editing options
|
||||||
|
const { data: posyanduList } = await supabase
|
||||||
|
.from('detail_posyandu')
|
||||||
|
.select('nama_posyandu')
|
||||||
|
.order('nama_posyandu', { ascending: true })
|
||||||
|
|
||||||
const formatDate = (d: string | null) => {
|
const formatDate = (d: string | null) => {
|
||||||
if (!d) return null
|
if (!d) return null
|
||||||
return new Date(d).toLocaleDateString('id-ID', {
|
return new Date(d).toLocaleDateString('id-ID', {
|
||||||
|
|
@ -172,7 +178,7 @@ export default async function DetailPenggunaKelolaPage({ params }: Props) {
|
||||||
<div className="flex-1 h-px bg-gradient-to-l from-transparent via-gray-200 to-gray-200" />
|
<div className="flex-1 h-px bg-gradient-to-l from-transparent via-gray-200 to-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart Perkembangan Tinggi & Berat */}
|
{/* Chart Perkembangan Panjang & Berat */}
|
||||||
<PerkembanganChart data={hasilData ?? []} />
|
<PerkembanganChart data={hasilData ?? []} />
|
||||||
|
|
||||||
{/* Professional Separator */}
|
{/* Professional Separator */}
|
||||||
|
|
@ -187,7 +193,7 @@ export default async function DetailPenggunaKelolaPage({ params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabel Riwayat Hasil Stunting */}
|
{/* Tabel Riwayat Hasil Stunting */}
|
||||||
<HasilStuntingTable data={hasilData ?? []} pengguna={pengguna} />
|
<HasilStuntingTable data={hasilData ?? []} pengguna={pengguna} posyanduList={posyanduList ?? []} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function InstantScheduleModal({ isOpen, onClose, adminName }: Props) {
|
||||||
<form action={formAction} className="p-6 flex flex-col gap-5">
|
<form action={formAction} className="p-6 flex flex-col gap-5">
|
||||||
<input type="hidden" name="edited_by" value={adminName} />
|
<input type="hidden" name="edited_by" value={adminName} />
|
||||||
<input type="hidden" name="jam_mulai" value="08:00" />
|
<input type="hidden" name="jam_mulai" value="08:00" />
|
||||||
<input type="hidden" name="jam_selesai" value="11:00" />
|
<input type="hidden" name="jam_selesai" value="10:00" />
|
||||||
|
|
||||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-xl flex gap-x-2">
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-xl flex gap-x-2">
|
||||||
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||||
|
|
@ -74,7 +74,7 @@ export function InstantScheduleModal({ isOpen, onClose, adminName }: Props) {
|
||||||
Penjadwalan harus dilakukan minimal 1 hari sebelumnya.
|
Penjadwalan harus dilakukan minimal 1 hari sebelumnya.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-blue-800 leading-relaxed font-bold uppercase tracking-tight">
|
<p className="text-[10px] text-blue-800 leading-relaxed font-bold uppercase tracking-tight">
|
||||||
Sesi tetap: 08:00 - 11:00 WIB (Selingan 1 Jam).
|
Sesi: 08-10, 11-13, 14-16 (Selingan 1 Jam).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +104,7 @@ export function InstantScheduleModal({ isOpen, onClose, adminName }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="h-0.5 w-4 bg-gray-300"></div>
|
<div className="h-0.5 w-4 bg-gray-300"></div>
|
||||||
<div className="px-3 py-1.5 bg-black text-white text-sm font-black rounded-lg">
|
<div className="px-3 py-1.5 bg-black text-white text-sm font-black rounded-lg">
|
||||||
11:00
|
10:00
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-auto">Fixed Slot</span>
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-auto">Fixed Slot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -222,97 +222,101 @@ export function JadwalTable({ data, userName }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className={`overflow-hidden rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] transition-colors ${showHistory ? 'bg-slate-50' : 'bg-white'}`}>
|
<div className={`rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] transition-colors ${showHistory ? 'bg-slate-50' : 'bg-white'} overflow-hidden`}>
|
||||||
<table className="w-full text-left border-collapse">
|
<div className="overflow-x-auto">
|
||||||
<thead className={`${showHistory ? 'bg-purple-600' : 'bg-black'} text-white transition-colors`}>
|
<div className="min-w-[1000px] md:min-w-full">
|
||||||
<tr>
|
<table className="w-full text-left border-collapse">
|
||||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center w-20">No</th>
|
<thead className={`${showHistory ? 'bg-purple-600' : 'bg-black'} text-white transition-colors`}>
|
||||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Waktu & Sesi</th>
|
<tr>
|
||||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Detail Posyandu</th>
|
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center w-20">No</th>
|
||||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Oleh Admin</th>
|
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Waktu & Sesi</th>
|
||||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center">Aksi</th>
|
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Detail Posyandu</th>
|
||||||
</tr>
|
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Oleh Admin</th>
|
||||||
</thead>
|
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center">Aksi</th>
|
||||||
<tbody className={`${showHistory ? 'bg-slate-50 divide-purple-100' : 'bg-white divide-gray-100'} divide-y-2`}>
|
|
||||||
{filteredData.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-24 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-gray-300">
|
|
||||||
<div className="p-4 bg-gray-50 rounded-full">
|
|
||||||
<Calendar className="w-12 h-12 opacity-20" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-black text-gray-400">Belum ada jadwal ditemukan</p>
|
|
||||||
<p className="text-xs font-semibold">Gunakan Penjadwalan Instan untuk membuat jadwal otomatis.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredData.map((j, idx) => (
|
|
||||||
<tr key={j.id} className={`${showHistory ? 'hover:bg-purple-100/50' : 'hover:bg-red-50/30'} transition-colors group`}>
|
|
||||||
<td className={`px-6 py-6 text-center font-black ${showHistory ? 'text-purple-300' : 'text-gray-300'} group-hover:text-red-300 text-lg`}>
|
|
||||||
{idx + 1}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-6">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className={`flex items-center gap-2 ${showHistory ? 'text-purple-600' : 'text-red-600'} font-black`}>
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span className="text-lg">{j.jam_mulai.slice(0, 5)} - {j.jam_selesai.slice(0, 5)}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold uppercase text-gray-400 tracking-widest">
|
|
||||||
{new Date(j.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-6">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className={`font-black text-base ${showHistory ? 'text-purple-900' : 'text-gray-900'} group-hover:text-red-600 transition-colors uppercase tracking-tight`}>
|
|
||||||
{j.detail_posyandu.nama_posyandu}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 font-semibold mt-1">
|
|
||||||
<Building2 className="w-3.5 h-3.5" />
|
|
||||||
<span className="line-clamp-1">{j.detail_posyandu.alamat}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-8 h-8 rounded-full ${showHistory ? 'bg-purple-100' : 'bg-gray-100'} border-2 border-white shadow-sm flex items-center justify-center text-[10px] font-black group-hover:bg-red-100 transition-colors`}>
|
|
||||||
{j.diedit_oleh.replace('[HISTORY] ', '').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-bold text-gray-700">{j.diedit_oleh.replace('[HISTORY] ', '')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-6">
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<CetakPDFJadwal jadwal={j} currentAdmin={userName} />
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditJadwal(j)}
|
|
||||||
className="p-2 text-gray-400 hover:text-black hover:bg-gray-100 rounded-lg transition-all"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteJadwal(j.id, j.detail_posyandu.nama_posyandu)}
|
|
||||||
disabled={isDeleting === j.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 === j.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className={`${showHistory ? 'bg-slate-50 divide-purple-100' : 'bg-white divide-gray-100'} divide-y-2`}>
|
||||||
</tbody>
|
{filteredData.length === 0 ? (
|
||||||
</table>
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-24 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-gray-300">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-full">
|
||||||
|
<Calendar className="w-12 h-12 opacity-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-black text-gray-400">Belum ada jadwal ditemukan</p>
|
||||||
|
<p className="text-xs font-semibold">Gunakan Penjadwalan Instan untuk membuat jadwal otomatis.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredData.map((j, idx) => (
|
||||||
|
<tr key={j.id} className={`${showHistory ? 'hover:bg-purple-100/50' : 'hover:bg-red-50/30'} transition-colors group`}>
|
||||||
|
<td className={`px-6 py-6 text-center font-black ${showHistory ? 'text-purple-300' : 'text-gray-300'} group-hover:text-red-300 text-lg`}>
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className={`flex items-center gap-2 ${showHistory ? 'text-purple-600' : 'text-red-600'} font-black`}>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="text-lg">{j.jam_mulai.slice(0, 5)} - {j.jam_selesai.slice(0, 5)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold uppercase text-gray-400 tracking-widest">
|
||||||
|
{new Date(j.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`font-black text-base ${showHistory ? 'text-purple-900' : 'text-gray-900'} group-hover:text-red-600 transition-colors uppercase tracking-tight`}>
|
||||||
|
{j.detail_posyandu.nama_posyandu}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 font-semibold mt-1">
|
||||||
|
<Building2 className="w-3.5 h-3.5" />
|
||||||
|
<span className="line-clamp-1">{j.detail_posyandu.alamat}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-8 h-8 rounded-full ${showHistory ? 'bg-purple-100' : 'bg-gray-100'} border-2 border-white shadow-sm flex items-center justify-center text-[10px] font-black group-hover:bg-red-100 transition-colors`}>
|
||||||
|
{j.diedit_oleh.replace('[HISTORY] ', '').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-700">{j.diedit_oleh.replace('[HISTORY] ', '')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-6">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<CetakPDFJadwal jadwal={j} currentAdmin={userName} />
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditJadwal(j)}
|
||||||
|
className="p-2 text-gray-400 hover:text-black hover:bg-gray-100 rounded-lg transition-all"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteJadwal(j.id, j.detail_posyandu.nama_posyandu)}
|
||||||
|
disabled={isDeleting === j.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 === j.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InstantScheduleModal
|
<InstantScheduleModal
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useActionState, useEffect, useState } from 'react'
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
import { updateAkunBalita } from '@/app/actions'
|
import { useRouter } from 'next/navigation'
|
||||||
import { User, MapPin, Phone, Baby, Calendar, Lock, AtSign, CheckCircle, XCircle, X } from 'lucide-react'
|
import { updateAkunBalita, deleteAkunBalita } from '@/app/actions'
|
||||||
|
import { User, MapPin, Phone, Baby, Calendar, Lock, AtSign, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
interface AkunBalita {
|
interface AkunBalita {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -22,7 +23,33 @@ interface Props {
|
||||||
import { showSwal } from '@/lib/swal'
|
import { showSwal } from '@/lib/swal'
|
||||||
|
|
||||||
export function EditPenggunaForm({ pengguna }: Props) {
|
export function EditPenggunaForm({ pengguna }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
const [state, formAction, isPending] = useActionState(updateAkunBalita, null)
|
const [state, formAction, isPending] = useActionState(updateAkunBalita, null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const confirmed = await showSwal.confirm(
|
||||||
|
'Hapus Akun Pengguna?',
|
||||||
|
'Tindakan ini akan menghapus akun balita dan seluruh riwayat pemeriksaan stunting terkait secara permanen!'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmed.isConfirmed) {
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteAkunBalita(pengguna.id)
|
||||||
|
if (res.success) {
|
||||||
|
await showSwal.success('Berhasil!', res.message)
|
||||||
|
router.push('/dashboard/manajemen-akun/pengguna')
|
||||||
|
} else {
|
||||||
|
showSwal.error('Gagal!', res.message)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showSwal.error('Gagal!', err.message || 'Terjadi kesalahan.')
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state) {
|
if (state) {
|
||||||
|
|
@ -117,15 +144,24 @@ export function EditPenggunaForm({ pengguna }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit & Delete Actions */}
|
||||||
<div className="pt-4">
|
<div className="pt-4 flex flex-col sm:flex-row gap-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending || isDeleting}
|
||||||
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"
|
className="flex-1 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'}
|
{isPending ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending || isDeleting}
|
||||||
|
className="bg-red-600 text-white font-bold py-4 px-6 rounded-lg hover:bg-red-700 transition-all shadow-[4px_4px_0px_0px_rgba(220,38,38,0.3)] hover:shadow-[2px_2px_0px_0px_rgba(220,38,38,0.3)] hover:translate-x-[2px] hover:translate-y-[2px] disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{isDeleting ? 'Menghapus...' : 'Hapus Akun'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -64,72 +64,76 @@ export default async function KelolaAkunPenggunaPage() {
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
<div className="rounded-xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
||||||
{/* Table Header */}
|
<div className="overflow-x-auto">
|
||||||
<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">
|
<div className="min-w-[800px] md:min-w-full">
|
||||||
<span>Nama Orang Tua</span>
|
{/* Table Header */}
|
||||||
<span>Nama Anak</span>
|
<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>No. WhatsApp</span>
|
<span>Nama Orang Tua</span>
|
||||||
<span>Username</span>
|
<span>Nama Anak</span>
|
||||||
<span className="text-center">Aksi</span>
|
<span>No. WhatsApp</span>
|
||||||
</div>
|
<span>Username</span>
|
||||||
|
<span className="text-center">Aksi</span>
|
||||||
{/* 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>
|
||||||
))
|
|
||||||
)}
|
{/* Table Rows */}
|
||||||
|
{!pengguna || pengguna.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-400 bg-white">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer note */}
|
{/* Footer note */}
|
||||||
|
|
|
||||||
|
|
@ -96,124 +96,128 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-hidden rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
<div className="rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
||||||
<table className="w-full text-left border-collapse">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-black text-white">
|
<div className="min-w-[900px] md:min-w-full">
|
||||||
<tr>
|
<table className="w-full text-left border-collapse">
|
||||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-16">No</th>
|
<thead className="bg-black text-white">
|
||||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Informasi Posyandu</th>
|
<tr>
|
||||||
<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 text-center w-16">No</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center">Lokasi</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 text-center w-40">Aksi</th>
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Petugas & Kontak</th>
|
||||||
</tr>
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center">Lokasi</th>
|
||||||
</thead>
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-40">Aksi</th>
|
||||||
<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-2 max-w-[250px]">
|
|
||||||
{p.petugas && p.petugas.length > 0 ? (
|
|
||||||
p.petugas.map((petugas, i) => (
|
|
||||||
<div key={i} className="flex flex-col border-l-2 border-purple-100 pl-3 py-0.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-bold text-gray-700 line-clamp-1">
|
|
||||||
{petugas.nama_petugas}
|
|
||||||
</span>
|
|
||||||
{petugas.jabatan && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-600 rounded font-black uppercase tracking-tighter">
|
|
||||||
{petugas.jabatan}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{petugas.nomor_hp && (
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
|
||||||
<Phone className="w-2.5 h-2.5" />
|
|
||||||
<span>{petugas.nomor_hp}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-gray-400 italic text-xs">
|
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<span>Belum ada petugas</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>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className="bg-white divide-y divide-gray-100">
|
||||||
</tbody>
|
{filteredData.length === 0 ? (
|
||||||
</table>
|
<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-2 max-w-[250px]">
|
||||||
|
{p.petugas && p.petugas.length > 0 ? (
|
||||||
|
p.petugas.map((petugas, i) => (
|
||||||
|
<div key={i} className="flex flex-col border-l-2 border-purple-100 pl-3 py-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-gray-700 line-clamp-1">
|
||||||
|
{petugas.nama_petugas}
|
||||||
|
</span>
|
||||||
|
{petugas.jabatan && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-600 rounded font-black uppercase tracking-tighter">
|
||||||
|
{petugas.jabatan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{petugas.nomor_hp && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
||||||
|
<Phone className="w-2.5 h-2.5" />
|
||||||
|
<span>{petugas.nomor_hp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 italic text-xs">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span>Belum ada petugas</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export default async function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
|
<header className="flex justify-between items-center px-4 md:px-8 py-4 md:py-6 border-b border-gray-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-full border border-black flex items-center justify-center">
|
<div className="p-2 rounded-full border border-black flex items-center justify-center">
|
||||||
<Activity className="h-5 w-5 text-black" />
|
<Activity className="h-5 w-5 text-black" />
|
||||||
|
|
@ -46,12 +46,12 @@ export default async function DashboardPage() {
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-8 max-w-6xl mx-auto">
|
<main className="p-4 md:p-8 max-w-6xl mx-auto w-full">
|
||||||
{/* Main Single Frame */}
|
{/* Main Single Frame */}
|
||||||
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
<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 */}
|
{/* 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 className="p-6 md:p-8 border-b border-black flex flex-col md:flex-row justify-between items-start md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2 mb-2">
|
<h2 className="text-2xl font-bold flex items-center gap-2 mb-2">
|
||||||
<Activity className="h-6 w-6" />
|
<Activity className="h-6 w-6" />
|
||||||
|
|
|
||||||
|
|
@ -240,45 +240,49 @@ export function StuntingChart({ data, availableYears }: Props) {
|
||||||
<p className="font-semibold text-gray-400">Tidak ada data untuk tahun {selectedYear}</p>
|
<p className="font-semibold text-gray-400">Tidak ada data untuk tahun {selectedYear}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<div className="overflow-x-auto pb-2 -mx-2 px-2">
|
||||||
{chartType === 'bar' ? (
|
<div className="min-w-[700px] md:min-w-full">
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }} barCategoryGap="30%">
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
{chartType === 'bar' ? (
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
<BarChart data={chartData} margin={{ top: 20, right: 10, left: -20, bottom: 0 }} barCategoryGap="25%">
|
||||||
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||||
<Legend
|
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
wrapperStyle={{ fontSize: 11, fontWeight: 600, paddingTop: 12 }}
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f8fafc' }} />
|
||||||
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
|
<Legend
|
||||||
/>
|
wrapperStyle={{ fontSize: 12, fontWeight: 700, paddingTop: 20 }}
|
||||||
<Bar dataKey="stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
|
||||||
<Bar dataKey="normal" fill="#10b981" radius={[4, 4, 0, 0]} />
|
/>
|
||||||
</BarChart>
|
<Bar dataKey="stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
) : (
|
<Bar dataKey="normal" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
</BarChart>
|
||||||
<defs>
|
) : (
|
||||||
<linearGradient id="stuntingGrad" x1="0" y1="0" x2="0" y2="1">
|
<AreaChart data={chartData} margin={{ top: 20, right: 10, left: -20, bottom: 0 }}>
|
||||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.25} />
|
<defs>
|
||||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
<linearGradient id="stuntingGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.25} />
|
||||||
<linearGradient id="normalGrad" x1="0" y1="0" x2="0" y2="1">
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
|
</linearGradient>
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
<linearGradient id="normalGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
|
||||||
</defs>
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
</linearGradient>
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
</defs>
|
||||||
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||||
<Legend
|
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
wrapperStyle={{ fontSize: 11, fontWeight: 600, paddingTop: 12 }}
|
<Tooltip content={<CustomTooltip />} />
|
||||||
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
|
<Legend
|
||||||
/>
|
wrapperStyle={{ fontSize: 12, fontWeight: 700, paddingTop: 20 }}
|
||||||
<Area type="monotone" dataKey="normal" stroke="#10b981" strokeWidth={2.5} fill="url(#normalGrad)" dot={{ r: 4, fill: '#10b981' }} />
|
formatter={(val) => val === 'stunting' ? 'Stunting' : 'Normal'}
|
||||||
<Area type="monotone" dataKey="stunting" stroke="#ef4444" strokeWidth={2.5} fill="url(#stuntingGrad)" dot={{ r: 4, fill: '#ef4444' }} />
|
/>
|
||||||
</AreaChart>
|
<Area type="monotone" dataKey="normal" stroke="#10b981" strokeWidth={3} fill="url(#normalGrad)" dot={{ r: 4, fill: '#10b981', strokeWidth: 2, stroke: 'white' }} />
|
||||||
)}
|
<Area type="monotone" dataKey="stunting" stroke="#ef4444" strokeWidth={3} fill="url(#stuntingGrad)" dot={{ r: 4, fill: '#ef4444', strokeWidth: 2, stroke: 'white' }} />
|
||||||
</ResponsiveContainer>
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -295,63 +299,66 @@ export function StuntingChart({ data, availableYears }: Props) {
|
||||||
<p className="text-sm">Tidak ada data untuk ditampilkan</p>
|
<p className="text-sm">Tidak ada data untuk ditampilkan</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<div className="overflow-x-auto pb-2 -mx-2 px-2">
|
||||||
<LineChart data={chartData} margin={{ top: 8, right: 16, left: -10, bottom: 0 }}>
|
<div className="min-w-[700px] md:min-w-full">
|
||||||
<defs>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<linearGradient id="prevalensiGrad" x1="0" y1="0" x2="0" y2="1">
|
<LineChart data={chartData} margin={{ top: 10, right: 20, left: -20, bottom: 0 }}>
|
||||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.15} />
|
<defs>
|
||||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
<linearGradient id="prevalensiGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.15} />
|
||||||
</defs>
|
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#fde68a" />
|
</linearGradient>
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
</defs>
|
||||||
<YAxis
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#fde68a" />
|
||||||
tick={{ fontSize: 11 }}
|
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||||
axisLine={false}
|
<YAxis
|
||||||
tickLine={false}
|
tick={{ fontSize: 11 }}
|
||||||
tickFormatter={(v) => `${v}%`}
|
axisLine={false}
|
||||||
domain={[0, 100]}
|
tickLine={false}
|
||||||
/>
|
tickFormatter={(v) => `${v}%`}
|
||||||
<Tooltip
|
domain={[0, 100]}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Tooltip
|
||||||
activeDot={{ r: 7, stroke: '#f59e0b', strokeWidth: 2 }}
|
content={({ active, payload, label }) => {
|
||||||
/>
|
if (active && payload && payload.length) {
|
||||||
</LineChart>
|
const val = payload[0]?.value as number
|
||||||
</ResponsiveContainer>
|
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} {selectedYear}</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 uppercase font-bold tracking-tight">
|
||||||
|
{val >= 20 ? '⚠️ Di atas ambang batas (20%)' : '✓ Di bawah ambang batas (20%)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="prevalensi"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={4}
|
||||||
|
dot={({ cx, cy, payload }) => (
|
||||||
|
<circle
|
||||||
|
key={`dot-${payload.month}`}
|
||||||
|
cx={cx} cy={cy} r={6}
|
||||||
|
fill={payload.prevalensi >= 20 ? '#ef4444' : '#10b981'}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
activeDot={{ r: 8, stroke: '#f59e0b', strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-amber-500 mt-2 text-center">
|
<p className="text-[10px] text-amber-500 mt-2 text-center">
|
||||||
● Merah = prevalensi ≥ 20% (tinggi) | ● Hijau = prevalensi < 20% (aman)
|
● Merah = prevalensi ≥ 20% (tinggi) | ● Hijau = prevalensi < 20% (aman)
|
||||||
|
|
|
||||||
28
app/page.tsx
28
app/page.tsx
|
|
@ -28,7 +28,7 @@ export default function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">HealthPortal</h1>
|
<h1 className="text-2xl font-bold tracking-tight">HealthPortal</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm tracking-widest uppercase">SISTEM INFORMASI KESEHATAN</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-4xl font-bold mb-8">Panduan Login</h2>
|
<h2 className="text-4xl font-bold mb-8">Panduan Login</h2>
|
||||||
|
|
@ -84,8 +84,8 @@ export default function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Login Form */}
|
{/* Right Side - Login Form */}
|
||||||
<div className="w-full lg:w-1/2 bg-white text-black flex flex-col items-center justify-center p-8 relative">
|
<div className="w-full lg:w-1/2 bg-white text-black flex flex-col items-center justify-center p-4 md:p-8 relative">
|
||||||
<div className="w-full max-w-md bg-white p-8 rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.1)] border border-gray-100 relative z-10 mb-10">
|
<div className="w-full max-w-md bg-white p-6 md:p-8 rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.1)] border border-gray-100 relative z-10 mb-10">
|
||||||
{/* Decorative Elements for Card style */}
|
{/* Decorative Elements for Card style */}
|
||||||
<div className="absolute top-0 left-0 w-full h-2 bg-black rounded-t-2xl"></div>
|
<div className="absolute top-0 left-0 w-full h-2 bg-black rounded-t-2xl"></div>
|
||||||
|
|
||||||
|
|
@ -135,6 +135,22 @@ export default function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="kode_unik_nik" className="text-xs font-bold uppercase tracking-wider text-gray-700">Kode Unik / NIK</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-2.5 text-gray-400">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="kode_unik_nik"
|
||||||
|
name="kode_unik_nik"
|
||||||
|
placeholder="Masukkan Kode Unik / NIK"
|
||||||
|
required
|
||||||
|
className="pl-10 h-12 bg-gray-50 border-gray-200 focus:border-black focus:ring-black transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox id="remember" name="remember" className="data-[state=checked]:bg-black data-[state=checked]:border-black" />
|
<Checkbox id="remember" name="remember" className="data-[state=checked]:bg-black data-[state=checked]:border-black" />
|
||||||
|
|
@ -169,11 +185,11 @@ export default function LoginPage() {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-xs font-bold text-gray-400 uppercase tracking-widest mt-auto mb-4">
|
<div className="flex flex-wrap justify-center gap-4 md:gap-6 text-xs font-bold text-gray-400 uppercase tracking-widest mt-auto mb-4 px-4 text-center">
|
||||||
<a href="#" className="hover:text-black transition-colors">Privasi</a>
|
<a href="#" className="hover:text-black transition-colors">Privasi</a>
|
||||||
<span className="text-gray-300">•</span>
|
<span className="hidden md:inline text-gray-300">•</span>
|
||||||
<a href="#" className="hover:text-black transition-colors">Ketentuan</a>
|
<a href="#" className="hover:text-black transition-colors">Ketentuan</a>
|
||||||
<span className="text-gray-300">•</span>
|
<span className="hidden md:inline text-gray-300">•</span>
|
||||||
<a href="#" className="hover:text-black transition-colors">Bantuan</a>
|
<a href="#" className="hover:text-black transition-colors">Bantuan</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-gray-300 mb-6">
|
<div className="text-[10px] text-gray-300 mb-6">
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,92 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"title": "Stunting: Apa, Penyebab, dan Upaya Penanganannya",
|
"title": "Peran Ibu dalam Mencegah Stunting Sejak Masa Sekarang",
|
||||||
"category": "Edukasi Dasar",
|
"category": "Peran Orang Tua",
|
||||||
"readTime": "5 min",
|
"readTime": "5 min",
|
||||||
"source": "Kemenkes RI",
|
"source": "Ayo Sehat Kemenkes",
|
||||||
"description": "Pelajari definisi stunting, penyebab utama seperti malnutrisi kronis, serta langkah-langkah penanganan yang efektif.",
|
"description": "Pelajari langkah-langkah krusial ibu dalam mencegah stunting, mulai dari pemberian ASI eksklusif hingga pemenuhan protein hewani.",
|
||||||
"url": "https://kesmas.kemkes.go.id"
|
"url": "https://ayosehat.kemkes.go.id/peran-ibu-cegah-stunting"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"title": "Pemahaman Orang Tua: Kunci Mencegah Stunting",
|
"title": "Stunting - Gejala, Penyebab, Pencegahan & Pengobatan",
|
||||||
"category": "Pola Asuh",
|
"category": "Edukasi Dasar",
|
||||||
"readTime": "4 min",
|
"readTime": "6 min",
|
||||||
"source": "Antara News",
|
"source": "Halodoc",
|
||||||
"description": "Menkes menekankan bahwa pemahaman orang tua mengenai nutrisi adalah faktor terpenting dalam mencegah gagal tumbuh pada anak.",
|
"description": "Panduan komprehensif mengenai stunting, mencakup ciri-ciri fisik, faktor penyebab, serta cara pencegahan dan penanganannya.",
|
||||||
"url": "https://www.antaranews.com"
|
"url": "https://www.halodoc.com/kesehatan/stunting"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3",
|
"id": "3",
|
||||||
"title": "Panduan Gizi Kemenkes untuk Balita",
|
"title": "Cegah Stunting Itu Penting",
|
||||||
"category": "Gizi & Nutrisi",
|
"category": "Kampanye Kesehatan",
|
||||||
"readTime": "6 min",
|
"readTime": "4 min",
|
||||||
"source": "Kemenkes RI",
|
"source": "Ayo Sehat Kemenkes",
|
||||||
"description": "Panduan terbaru mengenai asupan gizi seimbang, pentingnya protein hewani, dan suplementasi untuk balita.",
|
"description": "Memahami pentingnya pencegahan stunting sebagai prioritas kesehatan nasional untuk menciptakan generasi masa depan yang berkualitas.",
|
||||||
"url": "https://kemkes.go.id"
|
"url": "https://ayosehat.kemkes.go.id/cegah-stunting-itu-penting"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4",
|
"id": "4",
|
||||||
"title": "11 Program Intervensi Stunting Pemerintah",
|
"title": "Cegah Anak Stunting dengan 4 Cara Ini",
|
||||||
"category": "Program Pemerintah",
|
"category": "Panduan Praktis",
|
||||||
"readTime": "7 min",
|
"readTime": "5 min",
|
||||||
"source": "Kemenkes RI",
|
"source": "Halodoc",
|
||||||
"description": "Mengenal program pemerintah mulai dari pemberian TTD bagi remaja putri hingga imunisasi lengkap untuk bayi.",
|
"description": "Empat langkah praktis dan efektif yang dapat dilakukan orang tua di rumah untuk memastikan anak terhindar dari risiko stunting.",
|
||||||
"url": "https://kemkes.go.id"
|
"url": "https://www.halodoc.com/artikel/cegah-anak-stunting-dengan-4-cara-ini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "5",
|
"id": "5",
|
||||||
"title": "Peran Ibu dalam Mencegah Stunting dan Obesitas",
|
"title": "Manfaat Penimbangan Balita di Posyandu untuk Cegah Stunting",
|
||||||
"category": "Keluarga",
|
"category": "Layanan Kesehatan",
|
||||||
"readTime": "5 min",
|
"readTime": "4 min",
|
||||||
"source": "Promkes Kemenkes",
|
"source": "Ayo Sehat Kemenkes",
|
||||||
"description": "Diskusi mendalam mengenai peran krusial ibu dalam memantau berat badan selama kehamilan dan pemberian ASI eksklusif.",
|
"description": "Mengapa rutin ke Posyandu untuk pemantauan tumbuh kembang sangat krusial dalam deteksi dini dan pencegahan stunting pada balita.",
|
||||||
"url": "https://promkes.kemkes.go.id"
|
"url": "https://ayosehat.kemkes.go.id/manfaat-penimbangan-balita-di-posyandu"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6",
|
"id": "6",
|
||||||
"title": "Masalah Status Gizi Balita di Indonesia",
|
"title": "Cegah Stunting dengan Pola Asuh yang Baik",
|
||||||
"category": "Edukasi Dasar",
|
"category": "Pola Asuh",
|
||||||
"readTime": "6 min",
|
"readTime": "5 min",
|
||||||
"source": "Kemenkes RI",
|
"source": "Ayo Sehat Kemenkes",
|
||||||
"description": "Ulasan mengenai faktor langsung dan tidak langsung yang mempengaruhi status gizi balita di masa emas pertumbuhan.",
|
"description": "Bagaimana pola asuh yang tepat, termasuk praktik pemberian makan dan kebersihan lingkungan, berperan penting dalam mencegah stunting.",
|
||||||
"url": "https://kemkes.go.id"
|
"url": "https://ayosehat.kemkes.go.id/cegah-stunting-dengan-pola-asuh-yang-baik"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "7",
|
"id": "7",
|
||||||
"title": "Pentingnya Gizi Seimbang untuk Pertumbuhan Balita",
|
"title": "Ciri-Ciri dan Upaya Pencegahan Stunting",
|
||||||
"category": "Gizi & Nutrisi",
|
"category": "Edukasi Dasar",
|
||||||
"readTime": "4 min",
|
"readTime": "6 min",
|
||||||
"source": "Poltekkes Makassar",
|
"source": "Dinkes Kota Bandung",
|
||||||
"description": "Mengapa gizi seimbang di awal kehidupan menjadi fondasi kesehatan, sistem imun, dan kecerdasan anak di masa depan.",
|
"description": "Mengenali ciri-ciri khas anak stunting dan langkah-langkah strategis yang dapat diambil untuk melakukan intervensi pencegahan.",
|
||||||
"url": "https://poltekkes-mks.ac.id"
|
"url": "https://dinkes.bandung.go.id/ciri-ciri-dan-upaya-pencegahan-stunting/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "8",
|
"id": "8",
|
||||||
"title": "Ayo ke Posyandu: Deteksi Dini Stunting",
|
"title": "5 Ciri-Ciri Anak Stunting yang Perlu Orang Tua Waspadai",
|
||||||
"category": "Layanan Kesehatan",
|
"category": "Tips Kesehatan",
|
||||||
"readTime": "3 min",
|
"readTime": "4 min",
|
||||||
"source": "Stunting.go.id",
|
"source": "Alodokter",
|
||||||
"description": "Manfaat rutin menimbang berat badan dan memantau perkembangan anak di Posyandu sebagai langkah deteksi dini.",
|
"description": "Identifikasi lima tanda peringatan stunting pada anak yang sering terabaikan namun sangat penting untuk diwaspadai sejak dini.",
|
||||||
"url": "https://stunting.go.id"
|
"url": "https://www.alodokter.com/5-ciri-ciri-anak-stunting-yang-perlu-orang-tua-waspadai"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "9",
|
"id": "9",
|
||||||
"title": "Cegah Stunting itu Penting: Gerakan #AksiBergizi",
|
"title": "Faktor Pemicu Stunting pada Anak",
|
||||||
"category": "Kampanye",
|
"category": "Edukasi Dasar",
|
||||||
"readTime": "4 min",
|
"readTime": "6 min",
|
||||||
"source": "Ayo Sehat Kemenkes",
|
"source": "PAUDPEDIA Kemendikbud",
|
||||||
"description": "Informasi mengenai kampanye nasional pencegahan stunting melalui gerakan Aksi Bergizi dan Posyandu Aktif.",
|
"description": "Analisis mendalam mengenai berbagai faktor lingkungan dan kesehatan yang dapat memicu terjadinya kondisi stunting pada masa pertumbuhan anak.",
|
||||||
"url": "https://ayosehat.kemkes.go.id"
|
"url": "https://paudpedia.kemendikdasmen.go.id/komunitas-pembelajar/orang-tua-berbagi/faktor-pemicu-stunting-pada-anak"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "10",
|
"id": "10",
|
||||||
"title": "Langkah Praktis Mencegah Stunting di Rumah",
|
"title": "Pentingnya Nutrisi 1000 Hari Pertama Kehidupan",
|
||||||
"category": "Panduan Praktis",
|
"category": "Gizi & Nutrisi",
|
||||||
"readTime": "5 min",
|
"readTime": "5 min",
|
||||||
"source": "Ayo Sehat Kemenkes",
|
"source": "Kemenkes RI",
|
||||||
"description": "Tips harian bagi orang tua mulai dari stimulasi anak, menjaga kebersihan lingkungan, hingga pemenuhan gizi harian.",
|
"description": "Mengapa periode emas 1000 hari pertama kehidupan adalah waktu yang paling kritis untuk mencegah dampak permanen stunting.",
|
||||||
"url": "https://ayosehat.kemkes.go.id"
|
"url": "https://ayosehat.kemkes.go.id/peran-ibu-cegah-stunting"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { MapPin, Phone, User, ExternalLink, Map as MapIcon, Star, Send, Loader2, Building2 } from 'lucide-react'
|
import { MapPin, Phone, User, ExternalLink, Map as MapIcon, Star, Send, Loader2, Building2, Calendar, Clock } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { submitReview } from '../action-review'
|
import { submitReview } from '../action-review'
|
||||||
import { showSwal } from '@/lib/swal'
|
import { showSwal } from '@/lib/swal'
|
||||||
|
|
@ -19,6 +19,12 @@ interface Posyandu {
|
||||||
nomor_hp: string | null
|
nomor_hp: string | null
|
||||||
jabatan: string | null
|
jabatan: string | null
|
||||||
}[]
|
}[]
|
||||||
|
jadwal?: {
|
||||||
|
id: string
|
||||||
|
tanggal: string
|
||||||
|
jam_mulai: string
|
||||||
|
jam_selesai: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Review {
|
interface Review {
|
||||||
|
|
@ -119,6 +125,79 @@ export default function PosyanduDetailClient({ data, userId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Jadwal Pelaksanaan Card */}
|
||||||
|
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(239,68,68,0.2)] 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 lowercase">
|
||||||
|
<Calendar className="w-5 h-5 text-red-600" />
|
||||||
|
Jadwal Pelaksanaan
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[500px] md:min-w-full">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-400">Hari / Tanggal</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-400">Waktu</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-400">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 italic">
|
||||||
|
{data.jadwal && data.jadwal.length > 0 ? (
|
||||||
|
data.jadwal.map((j) => {
|
||||||
|
const scheduleDate = new Date(j.tanggal)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const isUpcoming = scheduleDate >= today
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={j.id} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-gray-900">
|
||||||
|
{scheduleDate.toLocaleDateString('id-ID', { weekday: 'long' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 font-semibold">
|
||||||
|
{scheduleDate.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-red-600 font-black">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-sm">{j.jam_mulai.slice(0, 5)} - {j.jam_selesai.slice(0, 5)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{isUpcoming ? (
|
||||||
|
<span className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded text-[10px] font-black uppercase tracking-widest border border-emerald-100">
|
||||||
|
Mendatang
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-gray-50 text-gray-400 rounded text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
||||||
|
Selesai
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-6 py-10 text-center text-gray-400 italic text-xs font-semibold">
|
||||||
|
Belum ada jadwal pelaksanaan yang terdaftar.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Petugas Card */}
|
{/* 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="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">
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ export default async function PosyanduDetailPage({ params }: Props) {
|
||||||
.from('detail_posyandu')
|
.from('detail_posyandu')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
petugas:petugas_posyandu_lokal(*)
|
petugas:petugas_posyandu_lokal(*),
|
||||||
|
jadwal:jadwal_posyandu(*)
|
||||||
`)
|
`)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.order('tanggal', { foreignTable: 'jadwal_posyandu', ascending: false })
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !posyandu) {
|
if (error || !posyandu) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default async function UserDashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
|
<header className="flex justify-between items-center px-4 md:px-8 py-4 md:py-6 border-b border-gray-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-full border border-black flex items-center justify-center">
|
<div className="p-2 rounded-full border border-black flex items-center justify-center">
|
||||||
<Activity className="h-5 w-5 text-black" />
|
<Activity className="h-5 w-5 text-black" />
|
||||||
|
|
@ -66,12 +66,12 @@ export default async function UserDashboardPage() {
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-8 max-w-6xl mx-auto">
|
<main className="p-4 md:p-8 max-w-6xl mx-auto w-full">
|
||||||
{/* Main Single Frame */}
|
{/* Main Single Frame */}
|
||||||
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
<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 */}
|
{/* 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 className="p-6 md:p-8 border-b border-black flex flex-col md:flex-row justify-between items-start md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2 mb-2">
|
<h2 className="text-2xl font-bold flex items-center gap-2 mb-2">
|
||||||
<Activity className="h-6 w-6" />
|
<Activity className="h-6 w-6" />
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface HasilItem {
|
||||||
id: number
|
id: number
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
status_stunting: boolean | null
|
status_stunting: boolean | null
|
||||||
pesan_ai: string | null
|
pesan_ai: string | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
|
|
@ -67,6 +68,7 @@ function build5MonthData(allData: HasilItem[], rowDate: Date) {
|
||||||
label,
|
label,
|
||||||
tinggi: match?.tinggi_badan ?? null,
|
tinggi: match?.tinggi_badan ?? null,
|
||||||
berat: match?.berat_badan ?? null,
|
berat: match?.berat_badan ?? null,
|
||||||
|
zscore: match?.z_score ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +76,7 @@ function build5MonthData(allData: HasilItem[], rowDate: Date) {
|
||||||
export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
const templateRef = useRef<HTMLDivElement>(null)
|
const templateRef = useRef<HTMLDivElement>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [idBerkas, setIdBerkas] = useState<number | null>(null)
|
||||||
|
|
||||||
const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date()
|
const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date()
|
||||||
const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload])
|
const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload])
|
||||||
|
|
@ -87,6 +90,23 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
if (!templateRef.current || loading) return
|
if (!templateRef.current || loading) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const currentIdBerkas = Date.now()
|
||||||
|
setIdBerkas(currentIdBerkas)
|
||||||
|
|
||||||
|
const { supabase } = await import('@/lib/supabase')
|
||||||
|
const { error: dbError } = await supabase.from('cetak_balita').insert({
|
||||||
|
id_berkas: currentIdBerkas,
|
||||||
|
nama_balita: pengguna.nama_anak,
|
||||||
|
tanggal_cetak: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
throw new Error(dbError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunggu render React selesai (untuk menampilkan ID Berkas)
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
|
||||||
const { default: html2canvas } = await import('html2canvas')
|
const { default: html2canvas } = await import('html2canvas')
|
||||||
const { default: jsPDF } = await import('jspdf')
|
const { default: jsPDF } = await import('jspdf')
|
||||||
|
|
||||||
|
|
@ -172,7 +192,7 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
['Nama Anak', pengguna.nama_anak],
|
['Nama Anak', pengguna.nama_anak],
|
||||||
['Alamat', pengguna.alamat ?? '-'],
|
['Alamat', pengguna.alamat ?? '-'],
|
||||||
['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'],
|
['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'],
|
||||||
['', ''],
|
['ID Berkas', idBerkas ? String(idBerkas) : '-'],
|
||||||
['Tanggal Lahir', tanggalLahir],
|
['Tanggal Lahir', tanggalLahir],
|
||||||
].map(([label, value], i) => (
|
].map(([label, value], i) => (
|
||||||
label ? (
|
label ? (
|
||||||
|
|
@ -191,9 +211,9 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
Grafik Pertumbuhan (5 Bulan Terakhir)
|
Grafik Pertumbuhan (5 Bulan Terakhir)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
{/* Tinggi */}
|
{/* Panjang */}
|
||||||
<div style={{ border: '1.5 solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#dbeafe' }}>
|
<div style={{ border: '1.5 solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#dbeafe' }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Tinggi Badan (cm)</div>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Panjang Badan (cm)</div>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -208,6 +228,7 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGradUser)"
|
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGradUser)"
|
||||||
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
@ -229,11 +250,34 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGradUser)"
|
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGradUser)"
|
||||||
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
||||||
connectNulls={false}
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Z-Score PDF Chart */}
|
||||||
|
<div style={{ marginTop: 16, border: '1.5px solid #f3e8ff', borderRadius: 10, padding: '12px 12px 2px', background: '#faf5ff', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#f3e8ff' }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#9333ea', marginBottom: 6 }}>📈 Z-Score (SD)</div>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 16, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pdfZscoreGradUser" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9d5ff" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
|
||||||
|
<Area type="monotone" dataKey="zscore" stroke="#9333ea" strokeWidth={2} fill="url(#pdfZscoreGradUser)"
|
||||||
|
dot={{ r: 3, fill: '#9333ea', stroke: 'white', strokeWidth: 2 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Data Pemeriksaan ── */}
|
{/* ── Data Pemeriksaan ── */}
|
||||||
|
|
@ -244,15 +288,16 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#111', color: '#fff' }}>
|
<tr style={{ background: '#111', color: '#fff' }}>
|
||||||
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
|
{['Panjang', 'Berat', 'Z-Score', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
|
||||||
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
|
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr style={{ background: '#f9fafb' }}>
|
<tr style={{ background: '#f9fafb' }}>
|
||||||
<td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td>
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} cm</td>
|
||||||
<td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td>
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} kg</td>
|
||||||
|
<td style={{ padding: '10px', fontWeight: 700 }}>{row.z_score ?? '-'} SD</td>
|
||||||
<td style={{ padding: '10px' }}>
|
<td style={{ padding: '10px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
|
@ -284,17 +329,6 @@ export function ExportPDFButton({ row, allData, pengguna }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── WhatsApp Info Box ── */}
|
|
||||||
<div style={{ marginTop: 16, padding: '10px 14px', borderRadius: 10, backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
|
||||||
<div style={{ fontSize: 16 }}>📱</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 9, fontWeight: 800, color: '#166534', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>Layanan Informasi WhatsApp</div>
|
|
||||||
<div style={{ fontSize: 10, lineHeight: 1.5, color: '#14532d' }}>
|
|
||||||
Untuk orang tua yang tidak memiliki akun WhatsApp, yuk segera buat akun karena kami melayani layanan penyampaian informasi hasil stunting dengan menggunakan WhatsApp agar mendapatkan informasi lebih cepat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Portal Access Info Box ── */}
|
{/* ── Portal Access Info Box ── */}
|
||||||
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
|
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ import {
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip,
|
XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { Ruler, Weight, ChevronDown } from 'lucide-react'
|
import { Ruler, Weight, ChevronDown, Activity } from 'lucide-react'
|
||||||
|
|
||||||
interface HasilItem {
|
interface HasilItem {
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ export function GrowthChart({ data }: Props) {
|
||||||
_date: new Date(d.tanggal_upload!).getTime(),
|
_date: new Date(d.tanggal_upload!).getTime(),
|
||||||
tinggi: d.tinggi_badan,
|
tinggi: d.tinggi_badan,
|
||||||
berat: d.berat_badan,
|
berat: d.berat_badan,
|
||||||
|
zscore: d.z_score,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a._date - b._date)
|
.sort((a, b) => a._date - b._date)
|
||||||
}, [data, selectedYear])
|
}, [data, selectedYear])
|
||||||
|
|
@ -89,7 +91,7 @@ export function GrowthChart({ data }: Props) {
|
||||||
Grafik Pertumbuhan Anak
|
Grafik Pertumbuhan Anak
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">
|
||||||
Statistik Tinggi & Berat {selectedYear}
|
Statistik Panjang, Berat & Z-Score {selectedYear}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,7 +120,7 @@ export function GrowthChart({ data }: Props) {
|
||||||
<Ruler className="w-5 h-5 text-blue-600" />
|
<Ruler className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-black text-blue-800 uppercase tracking-widest">Tinggi Badan</p>
|
<p className="text-xs font-black text-blue-800 uppercase tracking-widest">Panjang Badan</p>
|
||||||
<p className="text-[10px] text-blue-400 font-bold uppercase tracking-tighter">Satuan Centimeter (cm)</p>
|
<p className="text-[10px] text-blue-400 font-bold uppercase tracking-tighter">Satuan Centimeter (cm)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,6 +158,7 @@ export function GrowthChart({ data }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Weight Chart */}
|
||||||
<div className="rounded-2xl border-2 border-emerald-100 bg-emerald-50/20 p-6 flex flex-col gap-4">
|
<div className="rounded-2xl border-2 border-emerald-100 bg-emerald-50/20 p-6 flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -200,6 +203,52 @@ export function GrowthChart({ data }: Props) {
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Z-Score Chart */}
|
||||||
|
<div className="rounded-2xl border-2 border-purple-100 bg-purple-50/20 p-6 flex flex-col gap-4 md:col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-100 border border-purple-200 flex items-center justify-center shadow-sm">
|
||||||
|
<Activity className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-purple-800 uppercase tracking-widest">Z-Score (SD)</p>
|
||||||
|
<p className="text-[10px] text-purple-400 font-bold uppercase tracking-tighter">Standar Deviasi Pertumbuhan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="h-44 flex flex-col items-center justify-center text-gray-300 gap-2 border-2 border-dashed border-purple-100 rounded-xl">
|
||||||
|
<Activity className="w-8 h-8 opacity-20" />
|
||||||
|
<p className="text-[10px] font-black uppercase">Data belum tersedia</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -25, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zscoreGradUser" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#9333ea" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="#9333ea" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3e8ff" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 800, fill: '#9333ea' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
<Tooltip content={<MiniTooltip unit="SD" color="#9333ea" />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="zscore"
|
||||||
|
stroke="#9333ea"
|
||||||
|
strokeWidth={4}
|
||||||
|
fill="url(#zscoreGradUser)"
|
||||||
|
dot={{ r: 5, fill: '#9333ea', stroke: 'white', strokeWidth: 2 }}
|
||||||
|
activeDot={{ r: 8, strokeWidth: 4 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface HasilStunting {
|
||||||
id: number
|
id: number
|
||||||
tinggi_badan: number | null
|
tinggi_badan: number | null
|
||||||
berat_badan: number | null
|
berat_badan: number | null
|
||||||
|
z_score: number | null
|
||||||
status_stunting: boolean | null
|
status_stunting: boolean | null
|
||||||
pesan_ai: string | null
|
pesan_ai: string | null
|
||||||
tanggal_upload: string | null
|
tanggal_upload: string | null
|
||||||
|
|
@ -88,9 +89,10 @@ export function StuntingTable({ data, pengguna }: Props) {
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[1000px]">
|
<div className="min-w-[1000px]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[100px_100px_140px_1fr_160px_160px_120px] bg-black text-white px-6 py-4 text-[10px] font-black uppercase tracking-widest">
|
<div className="grid grid-cols-[100px_100px_100px_140px_1fr_160px_160px_120px] bg-black text-white px-6 py-4 text-[10px] font-black uppercase tracking-widest">
|
||||||
<span className="text-center">Tinggi</span>
|
<span className="text-center">Panjang</span>
|
||||||
<span className="text-center">Berat</span>
|
<span className="text-center">Berat</span>
|
||||||
|
<span className="text-center">Z-Score</span>
|
||||||
<span className="text-center">Status</span>
|
<span className="text-center">Status</span>
|
||||||
<span>Pesan / Rekomendasi</span>
|
<span>Pesan / Rekomendasi</span>
|
||||||
<span className="text-center">Posyandu</span>
|
<span className="text-center">Posyandu</span>
|
||||||
|
|
@ -111,9 +113,9 @@ export function StuntingTable({ data, pengguna }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="grid grid-cols-[100px_100px_140px_1fr_160px_160px_120px] items-center px-6 py-6 transition-colors hover:bg-gray-50/50"
|
className="grid grid-cols-[100px_100px_100px_140px_1fr_160px_160px_120px] items-center px-6 py-6 transition-colors hover:bg-gray-50/50"
|
||||||
>
|
>
|
||||||
{/* Tinggi Badan */}
|
{/* Panjang Badan */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="font-black text-lg text-black">{row.tinggi_badan ?? '-'}</span>
|
<span className="font-black text-lg text-black">{row.tinggi_badan ?? '-'}</span>
|
||||||
{row.tinggi_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">cm</span>}
|
{row.tinggi_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">cm</span>}
|
||||||
|
|
@ -125,6 +127,12 @@ export function StuntingTable({ data, pengguna }: Props) {
|
||||||
{row.berat_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">kg</span>}
|
{row.berat_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">kg</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Z-Score */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="font-black text-lg text-black">{row.z_score ?? '-'}</span>
|
||||||
|
{row.z_score !== null && <span className="text-[10px] text-gray-400 ml-1 font-bold">SD</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Stunting */}
|
{/* Status Stunting */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{row.status_stunting === null ? (
|
{row.status_stunting === null ? (
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default async function UserPerkembanganPage() {
|
||||||
// Fetch measurement history
|
// Fetch measurement history
|
||||||
const { data: hasilData } = await supabase
|
const { data: hasilData } = await supabase
|
||||||
.from('hasil_stunting_balita')
|
.from('hasil_stunting_balita')
|
||||||
.select('id, tinggi_badan, berat_badan, status_stunting, pesan_ai, tanggal_upload, nama_posyandu')
|
.select('id, tinggi_badan, berat_badan, z_score, status_stunting, pesan_ai, tanggal_upload, nama_posyandu')
|
||||||
.eq('id_balita', pengguna.id)
|
.eq('id_balita', pengguna.id)
|
||||||
.order('tanggal_upload', { ascending: false })
|
.order('tanggal_upload', { ascending: false })
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default async function UserPerkembanganPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100 bg-white">
|
<header className="flex justify-between items-center px-4 md:px-8 py-4 md:py-6 border-b border-gray-100 bg-white">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
|
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
|
||||||
<div className="p-2.5 rounded-full border-2 border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all shadow-sm">
|
<div className="p-2.5 rounded-full border-2 border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all shadow-sm">
|
||||||
|
|
@ -114,10 +114,10 @@ export default async function UserPerkembanganPage() {
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-10">
|
<main className="p-4 md:p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-6 md:gap-10">
|
||||||
|
|
||||||
{/* Hero Profile Section */}
|
{/* Hero Profile Section */}
|
||||||
<section className="bg-black text-white rounded-3xl p-8 lg:p-10 flex flex-col lg:flex-row items-center lg:items-end justify-between gap-8 shadow-[12px_12px_0px_0px_rgba(0,0,0,0.1)] relative overflow-hidden">
|
<section className="bg-black text-white rounded-3xl p-6 md:p-8 lg:p-10 flex flex-col lg:flex-row items-center lg:items-end justify-between gap-8 shadow-[12px_12px_0px_0px_rgba(0,0,0,0.1)] relative overflow-hidden">
|
||||||
{/* Background Decorative Element */}
|
{/* Background Decorative Element */}
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl pointer-events-none" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
|
@ -181,7 +181,7 @@ export default async function UserPerkembanganPage() {
|
||||||
|
|
||||||
{/* Chart Column */}
|
{/* Chart Column */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="bg-white rounded-3xl border-2 border-black p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,0.05)] h-full">
|
<div className="bg-white rounded-3xl border-2 border-black p-4 md:p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,0.05)] h-full">
|
||||||
<GrowthChart data={hasilData ?? []} />
|
<GrowthChart data={hasilData ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,7 +190,7 @@ export default async function UserPerkembanganPage() {
|
||||||
{/* Bottom Row: Full Width Table */}
|
{/* Bottom Row: Full Width Table */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="bg-white rounded-3xl border-2 border-gray-100 p-1">
|
<div className="bg-white rounded-3xl border-2 border-gray-100 p-1">
|
||||||
<div className="p-6 md:p-8">
|
<div className="p-4 md:p-8">
|
||||||
<StuntingTable data={hasilData ?? []} pengguna={pengguna} />
|
<StuntingTable data={hasilData ?? []} pengguna={pengguna} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -210,39 +210,43 @@ export function StuntingChart({ data, availableYears }: Props) {
|
||||||
<p className="font-bold">Data belum tersedia untuk tahun {selectedYear}</p>
|
<p className="font-bold">Data belum tersedia untuk tahun {selectedYear}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<div className="overflow-x-auto pb-2 -mx-2 px-2">
|
||||||
{chartType === 'bar' ? (
|
<div className="min-w-[700px] md:min-w-full">
|
||||||
<BarChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
{chartType === 'bar' ? (
|
||||||
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
<BarChart data={chartData} margin={{ top: 20, right: 10, left: -20, bottom: 0 }}>
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f8fafc' }} />
|
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
||||||
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="stunting" name="Stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f8fafc' }} />
|
||||||
<Bar dataKey="normal" name="Normal" fill="#10b981" radius={[4, 4, 0, 0]} />
|
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
|
||||||
</BarChart>
|
<Bar dataKey="stunting" name="Stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
) : (
|
<Bar dataKey="normal" name="Normal" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
</BarChart>
|
||||||
<defs>
|
) : (
|
||||||
<linearGradient id="colorStunting" x1="0" y1="0" x2="0" y2="1">
|
<AreaChart data={chartData} margin={{ top: 20, right: 10, left: -20, bottom: 0 }}>
|
||||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.1} />
|
<defs>
|
||||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
<linearGradient id="colorStunting" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.1} />
|
||||||
<linearGradient id="colorNormal" x1="0" y1="0" x2="0" y2="1">
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1} />
|
</linearGradient>
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
<linearGradient id="colorNormal" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1} />
|
||||||
</defs>
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
</linearGradient>
|
||||||
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
</defs>
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
||||||
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
|
||||||
<Area type="monotone" dataKey="stunting" name="Stunting" stroke="#ef4444" strokeWidth={3} fillOpacity={1} fill="url(#colorStunting)" />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Area type="monotone" dataKey="normal" name="Normal" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorNormal)" />
|
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
|
||||||
</AreaChart>
|
<Area type="monotone" dataKey="stunting" name="Stunting" stroke="#ef4444" strokeWidth={3} fillOpacity={1} fill="url(#colorStunting)" dot={{ r: 4, fill: '#ef4444', strokeWidth: 2, stroke: 'white' }} />
|
||||||
)}
|
<Area type="monotone" dataKey="normal" name="Normal" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorNormal)" dot={{ r: 4, fill: '#10b981', strokeWidth: 2, stroke: 'white' }} />
|
||||||
</ResponsiveContainer>
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -258,48 +262,53 @@ export function StuntingChart({ data, availableYears }: Props) {
|
||||||
<p className="text-sm font-bold opacity-30">Belum ada data prevalensi</p>
|
<p className="text-sm font-bold opacity-30">Belum ada data prevalensi</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<div className="overflow-x-auto pb-2 -mx-2 px-2">
|
||||||
<LineChart data={chartData} margin={{ top: 10, right: 20, left: -20, bottom: 0 }}>
|
<div className="min-w-[700px] md:min-w-full">
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#fef3c7" />
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
<LineChart data={chartData} margin={{ top: 10, right: 20, left: -20, bottom: 0 }}>
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} domain={[0, 100]} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#fef3c7" />
|
||||||
<Tooltip
|
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
|
||||||
content={({ active, payload, label }) => {
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} domain={[0, 100]} />
|
||||||
if (active && payload && payload.length) {
|
<Tooltip
|
||||||
const val = payload[0].value as number
|
content={({ active, payload, label }) => {
|
||||||
return (
|
if (active && payload && payload.length) {
|
||||||
<div className="bg-white border-2 border-amber-500 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
|
const val = payload[0].value as number
|
||||||
<p className="font-black text-black mb-1">{label} {selectedYear}</p>
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="bg-white border-2 border-amber-500 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
|
||||||
<span className={`w-3 h-3 rounded-full ${val >= 20 ? 'bg-red-500' : 'bg-emerald-500'}`} />
|
<p className="font-black text-black mb-1">{label} {selectedYear}</p>
|
||||||
<span className="font-black text-lg">{val}%</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span className={`w-3 h-3 rounded-full ${val >= 20 ? 'bg-red-500' : 'bg-emerald-500'}`} />
|
||||||
<p className="text-[10px] font-bold mt-1 uppercase tracking-tight">
|
<span className="font-black text-lg">{val}%</span>
|
||||||
{val >= 20 ? '🛑 Tinggi' : '✅ Aman'}
|
</div>
|
||||||
</p>
|
<p className="text-[10px] font-bold mt-1 uppercase tracking-tight">
|
||||||
</div>
|
{val >= 20 ? '🛑 Tinggi' : '✅ Aman'}
|
||||||
)
|
</p>
|
||||||
}
|
</div>
|
||||||
return null
|
)
|
||||||
}}
|
}
|
||||||
/>
|
return null
|
||||||
<Line
|
}}
|
||||||
type="monotone"
|
|
||||||
dataKey="prevalensi"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth={4}
|
|
||||||
dot={({ cx, cy, payload }) => (
|
|
||||||
<circle
|
|
||||||
key={`dot-${payload.month}`}
|
|
||||||
cx={cx} cy={cy} r={6}
|
|
||||||
fill={payload.prevalensi >= 20 ? '#ef4444' : '#10b981'}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Line
|
||||||
/>
|
type="monotone"
|
||||||
</LineChart>
|
dataKey="prevalensi"
|
||||||
</ResponsiveContainer>
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={4}
|
||||||
|
dot={({ cx, cy, payload }) => (
|
||||||
|
<circle
|
||||||
|
key={`dot-${payload.month}`}
|
||||||
|
cx={cx} cy={cy} r={6}
|
||||||
|
fill={payload.prevalensi >= 20 ? '#ef4444' : '#10b981'}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
activeDot={{ r: 8, stroke: '#f59e0b', strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function DashboardFooter() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-black text-white py-8 px-8 mt-auto w-full">
|
<footer className="bg-black text-white py-6 md:py-8 px-4 md:px-8 mt-auto w-full">
|
||||||
<div className="max-w-6xl mx-auto flex flex-col items-center gap-4 text-center">
|
<div className="max-w-6xl mx-auto flex flex-col items-center gap-4 text-center">
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,13 @@ export function FeatureCard({ title, description, icon: Icon, href, color, class
|
||||||
colorStyles[color]
|
colorStyles[color]
|
||||||
)}>
|
)}>
|
||||||
<div className="flex flex-col items-center text-center h-full">
|
<div className="flex flex-col items-center text-center h-full">
|
||||||
<div className={cn("w-16 h-16 rounded-full flex items-center justify-center mb-4 border-2 transition-transform group-hover:scale-110", iconBgStyles[color])}>
|
<div className={cn("w-12 h-12 sm:w-16 sm:h-16 rounded-full flex items-center justify-center mb-4 border-2 transition-transform group-hover:scale-110", iconBgStyles[color])}>
|
||||||
<Icon className="w-8 h-8" />
|
<Icon className="w-6 h-6 sm:w-8 sm:h-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-xl font-bold mb-2 text-black">{title}</h3>
|
<h3 className="text-lg sm:text-xl font-bold mb-2 text-black">{title}</h3>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-gray-500 text-sm leading-relaxed">{description}</p>
|
<p className="text-gray-500 text-xs sm:text-sm leading-relaxed">{description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Menurunkan/menonaktifkan kebijakan Row-Level Security (RLS) pada tabel cetak_balita
|
||||||
|
-- Agar sama seperti tabel lainnya di proyek ini (Unrestricted) dan memperbolehkan input data dari aplikasi.
|
||||||
|
|
||||||
|
ALTER TABLE public.cetak_balita DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -19,7 +23,9 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|
@ -30,5 +36,8 @@
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
}
|
"node_modules",
|
||||||
|
"tmp"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue