Admin Fix Chart Bar PerMonth Submenu IKM, Menu PPID

This commit is contained in:
2025-08-13 09:47:21 +08:00
parent a6832cad40
commit a035039b2c
4 changed files with 329 additions and 158 deletions

View File

@@ -117,8 +117,48 @@ const responden = proxy({
id: "", id: "",
form: { ...defaultFormResponden }, form: { ...defaultFormResponden },
loading: false, loading: false,
async byId() { async load(id: string) {
// Method implementation if needed if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
responden.update.loading = true;
const response = await fetch(`/api/landingpage/responden/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
name: data.name,
tanggal: data.tanggal,
jenisKelaminId: data.jenisKelaminId,
ratingId: data.ratingId,
kelompokUmurId: data.kelompokUmurId,
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading responden:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} finally {
responden.update.loading = false;
}
}, },
async submit() { async submit() {
const id = this.id; const id = this.id;

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
'use client'; 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { PieChart } from '@mantine/charts'; // ✅ Ganti recharts dengan Mantine import { PieChart, BarChart } from '@mantine/charts'; // ✅ Ganti recharts dengan Mantine
import { import {
Box, Box,
Center, Center,
@@ -25,12 +25,15 @@ interface ChartDataItem {
label?: string; label?: string;
} }
function Page() { function Page() {
const state = useProxy(indeksKepuasanState.responden); const state = useProxy(indeksKepuasanState.responden);
const { data, loading } = state.findMany; const { data, loading } = state.findMany;
const [donutDataJenisKelamin, setDonutDataJenisKelamin] = useState<ChartDataItem[]>([]); const [donutDataJenisKelamin, setDonutDataJenisKelamin] = useState<ChartDataItem[]>([]);
const [donutDataRating, setDonutDataRating] = useState<ChartDataItem[]>([]); const [donutDataRating, setDonutDataRating] = useState<ChartDataItem[]>([]);
const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]); const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]);
const [barChartData, setBarChartData] = useState<Array<{ month: string; count: number }>>([]);
useShallowEffect(() => { useShallowEffect(() => {
if (data) { if (data) {
@@ -69,6 +72,41 @@ function Page() {
{ name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' }, { name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' },
{ name: 'Lansia', value: totalLansia, color: '#FFA500' }, { name: 'Lansia', value: totalLansia, color: '#FFA500' },
]); ]);
// Process data for bar chart (group by month)
const monthYearMap = new Map<string, number>();
data.forEach((item: any) => {
// Try both createdAt and tanggal fields
const dateValue = item.tanggal || item.createdAt;
if (!dateValue) return;
const parsedDate = new Date(dateValue);
if (isNaN(parsedDate.getTime())) return;
const month = parsedDate.getMonth() + 1;
const year = parsedDate.getFullYear();
const monthYearKey = `${year}-${String(month).padStart(2, '0')}`;
monthYearMap.set(monthYearKey, (monthYearMap.get(monthYearKey) || 0) + 1);
});
// Convert map to array and sort by date
const barData = Array.from(monthYearMap.entries())
.map(([key, count]) => {
const [year, month] = key.split('-');
const monthName = new Date(Number(year), Number(month) - 1, 1)
.toLocaleString('id-ID', { month: 'long' });
return {
month: `${monthName} ${year}`,
count,
sortKey: parseInt(`${year}${String(month).padStart(2, '0')}`, 10)
};
})
.sort((a, b) => a.sortKey - b.sortKey)
.map(({ month, count }) => ({ month, count }));
setBarChartData(barData);
} }
}, [data]); }, [data]);
@@ -92,105 +130,120 @@ function Page() {
return ( return (
<Stack gap="xs"> <Stack gap="xs">
{/* Bar Chart - Data per Tanggal */}
<Paper bg={colors['white-1']} p="md" radius="md" mb="md">
<Title order={4} mb="md" ta="center">Jumlah Responden per Bulan</Title>
<Box h={300}>
<BarChart
h={300}
data={barChartData}
dataKey="month"
series={[{ name: 'count', color: colors['blue-button'] }]}
tickLine="y"
xAxisLabel="Bulan"
yAxisLabel="Jumlah Responden"
withTooltip
tooltipAnimationDuration={200}
/>
</Box>
</Paper>
<SimpleGrid cols={{ base: 1, md: 3 }}> <SimpleGrid cols={{ base: 1, md: 3 }}>
{/* Chart Jenis Kelamin */} {/* Chart Jenis Kelamin */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Jenis Kelamin</Title> <Title order={4}>Jenis Kelamin</Title>
{donutDataJenisKelamin.every(item => item.value === 0) ? ( {donutDataJenisKelamin.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik
</Text> </Text>
) : ( ) : (
<Box> <Box>
<Center> <Center>
<PieChart <PieChart
withLabels withLabels
withTooltip withTooltip
labelsType="percent" labelsType="percent"
size={250} size={250}
data={donutDataJenisKelamin} data={donutDataJenisKelamin}
/> />
</Center> </Center>
<Stack gap="sm" mt="md"> <Stack gap="sm" mt="md">
{donutDataJenisKelamin.map((entry) => ( {donutDataJenisKelamin.map((entry) => (
<Flex key={entry.name} gap="md" align="center"> <Flex key={entry.name} gap="md" align="center">
<Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} /> <Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} />
<Text size="sm">{entry.name}: {entry.value}</Text> <Text size="sm">{entry.name}: {entry.value}</Text>
</Flex> </Flex>
))} ))}
</Stack> </Stack>
</Box> </Box>
)} )}
</Stack> </Stack>
</Paper> </Paper>
{/* Chart Rating */} {/* Chart Rating */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Pilihan</Title> <Title order={4}>Pilihan</Title>
{donutDataRating.every(item => item.value === 0) ? ( {donutDataRating.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik
</Text> </Text>
) : ( ) : (
<Box> <Box>
<Center> <Center>
<PieChart <PieChart
withLabels withLabels
withTooltip withTooltip
labelsType="percent" labelsType="percent"
size={250} size={250}
data={donutDataRating} data={donutDataRating}
/> />
</Center> </Center>
<Stack gap="sm" mt="md"> <Stack gap="sm" mt="md">
{donutDataRating.map((entry) => ( {donutDataRating.map((entry) => (
<Flex key={entry.name} gap="md" align="center"> <Flex key={entry.name} gap="md" align="center">
<Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} /> <Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} />
<Text size="sm">{entry.name}: {entry.value}</Text> <Text size="sm">{entry.name}: {entry.value}</Text>
</Flex> </Flex>
))} ))}
</Stack> </Stack>
</Box> </Box>
)} )}
</Stack> </Stack>
</Paper> </Paper>
{/* Chart Kelompok Umur */} {/* Chart Kelompok Umur */}
<Paper bg={colors['white-1']} p="md" radius="md"> <Paper bg={colors['white-1']} p="md" radius="md">
<Stack> <Stack>
<Title order={4}>Umur</Title> <Title order={4}>Umur</Title>
{donutDataKelompokUmur.every(item => item.value === 0) ? ( {donutDataKelompokUmur.every(item => item.value === 0) ? (
<Text c="dimmed" ta="center" my="md"> <Text c="dimmed" ta="center" my="md">
Belum ada data untuk ditampilkan dalam grafik Belum ada data untuk ditampilkan dalam grafik
</Text> </Text>
) : ( ) : (
<Box> <Box>
<Center> <Center>
<PieChart <PieChart
withLabels withLabels
withTooltip withTooltip
labelsType="percent" labelsType="percent"
size={250} size={250}
data={donutDataKelompokUmur} data={donutDataKelompokUmur}
/> />
</Center> </Center>
<Stack gap="sm" mt="md"> <Stack gap="sm" mt="md">
{donutDataKelompokUmur.map((entry) => ( {donutDataKelompokUmur.map((entry) => (
<Flex key={entry.name} gap="md" align="center"> <Flex key={entry.name} gap="md" align="center">
<Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} /> <Box bg={entry.color} w={20} h={20} style={{ flexShrink: 0 }} />
<Text size="sm">{entry.name}: {entry.value}</Text> <Text size="sm">{entry.name}: {entry.value}</Text>
</Flex> </Flex>
))} ))}
</Stack> </Stack>
</Box> </Box>
)} )}
</Stack> </Stack>
</Paper> </Paper>
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
); );

View File

@@ -1,35 +1,78 @@
'use client' 'use client'
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Title, TextInput, Text, Select } from '@mantine/core'; import { Box, Button, Paper, Stack, Title, TextInput, Text, Select } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import indeksKepuasanState from '@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan'; import indeksKepuasanState from '@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan';
import { toast } from 'react-toastify';
interface FormResponden {
name: string;
tanggal: string;
jenisKelaminId: string;
ratingId: string;
kelompokUmurId: string;
}
function EditResponden() { function EditResponden() {
const router = useRouter() const router = useRouter()
const params = useParams() as { id: string } const params = useParams() as { id: string }
const state = useProxy(indeksKepuasanState.responden) const state = useProxy(indeksKepuasanState.responden)
const id = params.id const id = params.id
const [formData, setFormData] = useState<FormResponden>({
name: '',
tanggal: '',
jenisKelaminId: '',
ratingId: '',
kelompokUmurId: '',
})
useEffect(() => { useEffect(() => {
if (id) { indeksKepuasanState.jenisKelaminResponden.findMany.load();
state.findUnique.load(id).then(() => { indeksKepuasanState.pilihanRatingResponden.findMany.load();
const data = state.findUnique.data indeksKepuasanState.kelompokUmurResponden.findMany.load();
const loadResponden = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await state.update.load(id);
if (data) { if (data) {
const formattedDate = data.tanggal && !isNaN(new Date(data.tanggal).getTime())
? new Date(data.tanggal).toISOString().split('T')[0]
: '';
// ⬇️ FIX PENTING: tambahkan ini
state.update.id = id;
state.update.form = { state.update.form = {
name: data.name || '', name: data.name,
tanggal: data.tanggal ? new Date(data.tanggal).toISOString() : new Date().toISOString(), tanggal: formattedDate,
jenisKelaminId: data.jenisKelaminId || '', jenisKelaminId: data.jenisKelaminId,
ratingId: data.ratingId || '', ratingId: data.ratingId,
kelompokUmurId: data.kelompokUmurId || '', kelompokUmurId: data.kelompokUmurId,
} };
setFormData({
name: data.name,
tanggal: data.tanggal,
jenisKelaminId: data.jenisKelaminId,
ratingId: data.ratingId,
kelompokUmurId: data.kelompokUmurId,
});
} }
}) } catch (error) {
console.error("Error loading program penghijauan:", error);
toast.error("Gagal memuat data program penghijauan");
}
} }
}, [id])
loadResponden();
}, [params?.id]);
const handleSubmit = async () => { const handleSubmit = async () => {
state.update.id = id; state.update.id = id;
@@ -51,74 +94,84 @@ function EditResponden() {
label="Nama" label="Nama"
type='text' type='text'
placeholder="masukkan nama" placeholder="masukkan nama"
value={state.update.form.name} value={formData.name}
onChange={(val) => { onChange={(val) => {
state.update.form.name = val.currentTarget.value; setFormData({
...formData,
name: val.currentTarget.value
})
}} }}
/> />
<TextInput <TextInput
label="Tanggal"
type="date" type="date"
placeholder="masukkan tanggal" value={formData.tanggal}
value={state.update.form.tanggal} onChange={(e) => {
onChange={(val) => { setFormData({
state.update.form.tanggal = val.currentTarget.value; ...formData,
tanggal: e.currentTarget.value, // ✅ sudah format YYYY-MM-DD
});
}} }}
/> />
<Select <Select
value={state.update.form.jenisKelaminId} key={"jenisKelamin"}
onChange={(val) => { label={<Text fw="bold" fz="sm">Jenis Kelamin</Text>}
state.update.form.jenisKelaminId = val || ""; placeholder="Pilih jenis kelamin"
}} value={formData.jenisKelaminId}
label={<Text fw={"bold"} fz={"sm"}>Jenis Kelamin</Text>} onChange={(val) => setFormData({ ...formData, jenisKelaminId: val || "" })}
placeholder='Pilih jenis kelamin'
data={ data={
indeksKepuasanState.jenisKelaminResponden.findMany.data?.map((v) => ({ (indeksKepuasanState.jenisKelaminResponden.findMany.data || [])
value: v.id, .filter(Boolean) // Hapus null/undefined
label: v.nama .map((v) => ({
})) || [] value: v.id || '',
label: typeof v.name === 'string' ? v.name : 'Tanpa Nama'
}))
} }
disabled={indeksKepuasanState.jenisKelaminResponden.findMany.loading} // ✅ disable saat loading
clearable clearable
searchable searchable
required required
error={!state.update.form.jenisKelaminId ? "Pilih jenis kelamin" : undefined} error={!formData.jenisKelaminId ? "Pilih jenis kelamin" : undefined}
/> />
<Select <Select
value={state.update.form.ratingId} key={"rating"}
onChange={(val) => { value={formData.ratingId}
state.update.form.ratingId = val || ""; onChange={(val) => setFormData({ ...formData, ratingId: val || "" })}
}}
label={<Text fw={"bold"} fz={"sm"}>Rating</Text>} label={<Text fw={"bold"} fz={"sm"}>Rating</Text>}
placeholder='Pilih rating' placeholder='Pilih rating'
data={ data={
indeksKepuasanState.pilihanRatingResponden.findMany.data?.map((v) => ({ (indeksKepuasanState.pilihanRatingResponden.findMany.data || [])
value: v.id, .filter(Boolean)
label: v.nama .map((v) => ({
})) || [] value: v.id || '',
label: typeof v.name === 'string' ? v.name : 'Tanpa Nama'
}))
} }
disabled={indeksKepuasanState.pilihanRatingResponden.findMany.loading}
clearable clearable
searchable searchable
required required
error={!state.update.form.ratingId ? "Pilih rating" : undefined} error={!formData.ratingId ? "Pilih rating" : undefined}
/> />
<Select <Select
value={state.update.form.kelompokUmurId} key={"kelompokUmur"}
onChange={(val) => { value={formData.kelompokUmurId}
state.update.form.kelompokUmurId = val || ""; onChange={(val) => setFormData({ ...formData, kelompokUmurId: val || "" })}
}}
label={<Text fw={"bold"} fz={"sm"}>Kelompok Umur</Text>} label={<Text fw={"bold"} fz={"sm"}>Kelompok Umur</Text>}
placeholder='Pilih kelompok umur' placeholder='Pilih kelompok umur'
data={ data={
indeksKepuasanState.kelompokUmurResponden.findMany.data?.map((v) => ({ (indeksKepuasanState.kelompokUmurResponden.findMany.data || [])
value: v.id, .filter(Boolean)
label: v.nama .map((v) => ({
})) || [] value: v.id || '',
label: typeof v.name === 'string' ? v.name : 'Tanpa Nama'
}))
} }
disabled={indeksKepuasanState.kelompokUmurResponden.findMany.loading}
clearable clearable
searchable searchable
required required
error={!state.update.form.kelompokUmurId ? "Pilih kelompok umur" : undefined} error={!formData.kelompokUmurId ? "Pilih kelompok umur" : undefined}
/> />
<Button <Button

View File

@@ -3,14 +3,14 @@
import { ModalKonfirmasiHapus } from "@/app/admin/(dashboard)/_com/modalKonfirmasiHapus" import { ModalKonfirmasiHapus } from "@/app/admin/(dashboard)/_com/modalKonfirmasiHapus"
import indeksKepuasanState from "@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan" import indeksKepuasanState from "@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan"
import colors from "@/con/colors" import colors from "@/con/colors"
import { Box, Button, Paper, Skeleton, Stack, Text } from "@mantine/core" import { Box, Button, Flex, Paper, Skeleton, Stack, Text } from "@mantine/core"
import { useShallowEffect } from "@mantine/hooks" import { useShallowEffect } from "@mantine/hooks"
import { IconArrowBack } from "@tabler/icons-react" import { IconArrowBack, IconEdit, IconX } from "@tabler/icons-react"
import { useRouter, useParams } from "next/navigation" import { useRouter, useParams } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { useProxy } from "valtio/utils" import { useProxy } from "valtio/utils"
export default function DetailResponden(){ export default function DetailResponden() {
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false)
const stateDetail = useProxy(indeksKepuasanState.responden) const stateDetail = useProxy(indeksKepuasanState.responden)
const router = useRouter() const router = useRouter()
@@ -30,17 +30,17 @@ export default function DetailResponden(){
} }
} }
if(!stateDetail.findUnique.data){ if (!stateDetail.findUnique.data) {
return( return (
<Stack> <Stack>
<Skeleton h={500} /> <Skeleton h={500} />
</Stack> </Stack>
) )
} }
return( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
@@ -57,9 +57,9 @@ export default function DetailResponden(){
<Box> <Box>
<Text fz={"lg"} fw={"bold"}>Tanggal</Text> <Text fz={"lg"} fw={"bold"}>Tanggal</Text>
<Text fz={"lg"}>{ <Text fz={"lg"}>{
stateDetail.findUnique.data?.tanggal stateDetail.findUnique.data?.tanggal
? new Date(stateDetail.findUnique.data.tanggal).toLocaleDateString('id-ID') ? new Date(stateDetail.findUnique.data.tanggal).toLocaleDateString('id-ID')
: '-' : '-'
}</Text> }</Text>
</Box> </Box>
<Box> <Box>
@@ -74,6 +74,31 @@ export default function DetailResponden(){
<Text fz={"lg"} fw={"bold"}>Kelompok Umur</Text> <Text fz={"lg"} fw={"bold"}>Kelompok Umur</Text>
<Text fz={"lg"}>{stateDetail.findUnique.data?.kelompokUmur?.name}</Text> <Text fz={"lg"}>{stateDetail.findUnique.data?.kelompokUmur?.name}</Text>
</Box> </Box>
<Flex gap={"xs"} mt={10}>
<Button
onClick={() => {
if (stateDetail.findUnique.data) {
setSelectedId(stateDetail.findUnique.data.id);
setModalHapus(true);
}
}}
disabled={stateDetail.delete.loading || !stateDetail.findUnique.data}
color={"red"}
>
<IconX size={20} />
</Button>
<Button
onClick={() => {
if (stateDetail.findUnique.data) {
router.push(`/admin/ppid/ikm-desa-darmasaba/responden/${stateDetail.findUnique.data.id}/edit`);
}
}}
disabled={!stateDetail.findUnique.data}
color={"green"}
>
<IconEdit size={20} />
</Button>
</Flex>
</Stack> </Stack>
</Paper> </Paper>
</Stack> </Stack>
@@ -85,7 +110,7 @@ export default function DetailResponden(){
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus responden ini?" text="Apakah anda yakin ingin menghapus responden ini?"
/> />
</Box> </Box>
) )
} }