207 lines
11 KiB
TypeScript
207 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import {
|
|
AreaChart, Area,
|
|
XAxis, YAxis, CartesianGrid, Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts'
|
|
import { Ruler, Weight, ChevronDown } from 'lucide-react'
|
|
|
|
interface HasilItem {
|
|
tinggi_badan: number | null
|
|
berat_badan: number | null
|
|
tanggal_upload: string | null
|
|
}
|
|
|
|
interface Props {
|
|
data: HasilItem[]
|
|
}
|
|
|
|
const START_YEAR = 2026
|
|
|
|
function MiniTooltip({ active, payload, label, unit, color }: any) {
|
|
if (!active || !payload?.length) return null
|
|
return (
|
|
<div className="bg-white border-2 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-3 py-2 text-xs"
|
|
style={{ borderColor: color }}>
|
|
<p className="font-black text-gray-700 mb-1">{label}</p>
|
|
<p className="font-black flex items-center gap-1" style={{ color }}>
|
|
<span className="text-lg">{payload[0]?.value ?? '-'}</span>
|
|
<span className="font-bold text-[10px] uppercase opacity-60">{unit}</span>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function GrowthChart({ data }: Props) {
|
|
const availableYears = useMemo(() => {
|
|
const currentYear = new Date().getFullYear()
|
|
const dataYears = Array.from(
|
|
new Set(
|
|
data
|
|
.map(d => d.tanggal_upload ? new Date(d.tanggal_upload).getFullYear() : null)
|
|
.filter(Boolean)
|
|
)
|
|
) as number[]
|
|
const maxYear = Math.max(currentYear, ...dataYears, START_YEAR)
|
|
return Array.from(
|
|
new Set([
|
|
...Array.from({ length: maxYear - START_YEAR + 1 }, (_, i) => START_YEAR + i),
|
|
...dataYears,
|
|
])
|
|
).filter(y => y >= START_YEAR).sort((a, b) => a - b)
|
|
}, [data])
|
|
|
|
const [selectedYear, setSelectedYear] = useState<number>(availableYears[availableYears.length - 1] ?? START_YEAR)
|
|
|
|
const chartData = useMemo(() => {
|
|
return data
|
|
.filter(d => {
|
|
if (!d.tanggal_upload) return false
|
|
return new Date(d.tanggal_upload).getFullYear() === selectedYear
|
|
})
|
|
.map(d => ({
|
|
label: new Date(d.tanggal_upload!).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }),
|
|
_date: new Date(d.tanggal_upload!).getTime(),
|
|
tinggi: d.tinggi_badan,
|
|
berat: d.berat_badan,
|
|
}))
|
|
.sort((a, b) => a._date - b._date)
|
|
}, [data, selectedYear])
|
|
|
|
const hasData = chartData.length > 0
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex items-center justify-between flex-wrap gap-3 border-b border-gray-100 pb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex -space-x-2">
|
|
<div className="w-8 h-8 rounded-full bg-blue-500 border-2 border-white shadow-sm flex items-center justify-center">
|
|
<Ruler className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div className="w-8 h-8 rounded-full bg-emerald-500 border-2 border-white shadow-sm flex items-center justify-center">
|
|
<Weight className="w-4 h-4 text-white" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-black uppercase tracking-tight text-gray-900 leading-none mb-1">
|
|
Grafik Pertumbuhan Anak
|
|
</p>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">
|
|
Statistik Tinggi & Berat {selectedYear}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-black uppercase tracking-widest text-gray-500">Pilih Tahun:</span>
|
|
<div className="relative">
|
|
<select
|
|
value={selectedYear}
|
|
onChange={e => setSelectedYear(Number(e.target.value))}
|
|
className="appearance-none border-2 border-black rounded-xl pl-4 pr-10 py-2 text-xs font-black bg-white focus:outline-none shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
|
|
>
|
|
{availableYears.map(y => (
|
|
<option key={y} value={y}>{y}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none text-gray-900" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="rounded-2xl border-2 border-blue-100 bg-blue-50/20 p-6 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center shadow-sm">
|
|
<Ruler className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-black text-blue-800 uppercase tracking-widest">Tinggi Badan</p>
|
|
<p className="text-[10px] text-blue-400 font-bold uppercase tracking-tighter">Satuan Centimeter (cm)</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-blue-100 rounded-xl">
|
|
<Ruler 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="tinggiGradUser" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#dbeafe" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 800, fill: '#3b82f6' }} axisLine={false} tickLine={false} />
|
|
<YAxis tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }} axisLine={false} tickLine={false} />
|
|
<Tooltip content={<MiniTooltip unit="cm" color="#3b82f6" />} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="tinggi"
|
|
stroke="#3b82f6"
|
|
strokeWidth={4}
|
|
fill="url(#tinggiGradUser)"
|
|
dot={{ r: 5, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
|
activeDot={{ r: 8, strokeWidth: 4 }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
|
|
<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 gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-emerald-100 border border-emerald-200 flex items-center justify-center shadow-sm">
|
|
<Weight className="w-5 h-5 text-emerald-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-black text-emerald-800 uppercase tracking-widest">Berat Badan</p>
|
|
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-tighter">Satuan Kilogram (kg)</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-emerald-100 rounded-xl">
|
|
<Weight 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="beratGradUser" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#d1fae5" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 800, fill: '#10b981' }} axisLine={false} tickLine={false} />
|
|
<YAxis tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }} axisLine={false} tickLine={false} />
|
|
<Tooltip content={<MiniTooltip unit="kg" color="#10b981" />} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="berat"
|
|
stroke="#10b981"
|
|
strokeWidth={4}
|
|
fill="url(#beratGradUser)"
|
|
dot={{ r: 5, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
|
activeDot={{ r: 8, strokeWidth: 4 }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|