Fix Admin Submenu Posyandu, Menu Kesehatan, dan Sinkronisasi UI & API Admin - User Submenu Posyandu

This commit is contained in:
2025-08-14 11:48:57 +08:00
parent c99416c7f8
commit 5e137ba658
13 changed files with 273 additions and 157 deletions

View File

@@ -1027,16 +1027,17 @@ model DoctorSign {
// ========================================= POSYANDU ========================================= // // ========================================= POSYANDU ========================================= //
model Posyandu { model Posyandu {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
nomor String nomor String
deskripsi String deskripsi String
image FileStorage @relation(fields: [imageId], references: [id]) jadwalPelayanan String
imageId String image FileStorage @relation(fields: [imageId], references: [id])
createdAt DateTime @default(now()) imageId String
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
deletedAt DateTime @default(now()) updatedAt DateTime @updatedAt
isActive Boolean @default(true) deletedAt DateTime @default(now())
isActive Boolean @default(true)
} }
// ========================================= PUSKESMAS ========================================= // // ========================================= PUSKESMAS ========================================= //

View File

@@ -125,8 +125,8 @@ import pelayananTelunjukSaktiDesa from "./data/desa/layanan/pelayananTelunjukSak
} }
console.log("penghargaan success ..."); console.log("penghargaan success ...");
// =========== LAYANAN DESA =========== // =========== LAYANAN DESA ===========
for (const p of pelayananSuratKeterangan) { for (const p of pelayananSuratKeterangan) {
await prisma.pelayananSuratKeterangan.upsert({ await prisma.pelayananSuratKeterangan.upsert({
where: { id: p.id }, where: { id: p.id },
update: { update: {
@@ -317,63 +317,42 @@ import pelayananTelunjukSaktiDesa from "./data/desa/layanan/pelayananTelunjukSak
console.log("visi misi desa success ..."); console.log("visi misi desa success ...");
// Flatten the nested array structure for posisiOrganisasiPPID const flattenedPosisi = posisiOrganisasiPPID.flat();
const flattenedPosisiOrganisasiPPID = posisiOrganisasiPPID.flat();
// ✅ Urutkan berdasarkan hierarki
const sortedPosisi = flattenedPosisi.sort((a, b) => a.hierarki - b.hierarki);
for (const p of sortedPosisi) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
if (p.parentId) {
const parentExists = flattenedPosisi.some((pos) => pos.id === p.parentId);
if (!parentExists) {
console.warn(
`⚠️ Parent tidak ditemukan: ${p.parentId} untuk ${p.nama}`
);
continue;
}
}
for (const p of flattenedPosisiOrganisasiPPID) {
await prisma.posisiOrganisasiPPID.upsert({ await prisma.posisiOrganisasiPPID.upsert({
where: { where: { id: p.id },
id: p.id, update: p,
}, create: p,
update: {
nama: p.nama,
deskripsi: p.deskripsi,
hierarki: p.hierarki,
parentId: p.parentId,
},
create: {
id: p.id,
nama: p.nama,
deskripsi: p.deskripsi,
hierarki: p.hierarki,
parentId: p.parentId,
},
}); });
} }
console.log("posisi organisasi success ..."); console.log("✅ Posisi organisasi berhasil");
// Flatten the nested array structure for pegawaiPPID // 2. Seed Pegawai
const flattenedPegawaiPPID = pegawaiPPID.flat(); const flattenedPegawai = pegawaiPPID.flat();
for (const p of flattenedPegawai) {
for (const p of flattenedPegawaiPPID) {
await prisma.pegawaiPPID.upsert({ await prisma.pegawaiPPID.upsert({
where: { where: { id: p.id },
id: p.id, update: p,
}, create: p,
update: {
namaLengkap: p.namaLengkap,
tanggalMasuk: new Date(p.tanggalMasuk),
email: p.email,
gelarAkademik: p.gelarAkademik,
telepon: p.telepon,
alamat: p.alamat,
posisiId: p.posisiId,
isActive: p.isActive,
},
create: {
id: p.id,
namaLengkap: p.namaLengkap,
tanggalMasuk: new Date(p.tanggalMasuk),
email: p.email,
gelarAkademik: p.gelarAkademik,
telepon: p.telepon,
alamat: p.alamat,
posisiId: p.posisiId,
isActive: p.isActive,
},
}); });
} }
console.log("pegawai success ..."); console.log("✅ Pegawai berhasil");
for (const l of pelayananPerizinanBerusaha) { for (const l of pelayananPerizinanBerusaha) {
await prisma.pelayananPerizinanBerusaha.upsert({ await prisma.pelayananPerizinanBerusaha.upsert({

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -9,6 +10,7 @@ const templateForm = z.object({
nomor: z.string().min(1, { message: "Nomor is required" }), nomor: z.string().min(1, { message: "Nomor is required" }),
deskripsi: z.string().min(1, { message: "Deskripsi is required" }), deskripsi: z.string().min(1, { message: "Deskripsi is required" }),
imageId: z.string().nonempty(), imageId: z.string().nonempty(),
jadwalPelayanan: z.string().min(1, { message: "Jadwal Pelayanan is required" }),
}); });
const defaultForm = { const defaultForm = {
@@ -16,6 +18,7 @@ const defaultForm = {
nomor: "", nomor: "",
deskripsi: "", deskripsi: "",
imageId: "", imageId: "",
jadwalPelayanan: "",
}; };
const posyandustate = proxy({ const posyandustate = proxy({
@@ -51,18 +54,42 @@ const posyandustate = proxy({
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.PosyanduGetPayload<{ | Prisma.PosyanduGetPayload<{
include: { include: {
image: true; image: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
posyandustate.findMany.loading = true; // ✅ Akses langsung via nama path
posyandustate.findMany.page = page;
posyandustate.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.kesehatan.posyandu["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
posyandustate.findMany.data = res.data.data ?? [];
posyandustate.findMany.totalPages = res.data.totalPages ?? 1;
} else {
posyandustate.findMany.data = [];
posyandustate.findMany.totalPages = 1;
} }
}>[] } catch (err) {
| null, console.error("Gagal fetch posyandu paginated:", err);
async load() { posyandustate.findMany.data = [];
const res = await ApiFetch.api.kesehatan.posyandu["find-many"].get(); posyandustate.findMany.totalPages = 1;
if (res.status === 200) { } finally {
posyandustate.findMany.data = res.data?.data ?? []; posyandustate.findMany.loading = false;
} }
} },
}, },
findUnique: { findUnique: {
data: null as data: null as
@@ -148,6 +175,7 @@ const posyandustate = proxy({
nomor: data.nomor, nomor: data.nomor,
deskripsi: data.deskripsi, deskripsi: data.deskripsi,
imageId: data.imageId || "", imageId: data.imageId || "",
jadwalPelayanan: data.jadwalPelayanan || "",
}; };
return data; return data;
} else { } else {
@@ -181,6 +209,7 @@ const posyandustate = proxy({
nomor: this.form.nomor, nomor: this.form.nomor,
deskripsi: this.form.deskripsi, deskripsi: this.form.deskripsi,
imageId: this.form.imageId, imageId: this.form.imageId,
jadwalPelayanan: this.form.jadwalPelayanan,
}), }),
}); });

View File

@@ -25,6 +25,7 @@ function EditPosyandu() {
nomor: statePosyandu.edit.form.nomor || '', nomor: statePosyandu.edit.form.nomor || '',
deskripsi: statePosyandu.edit.form.deskripsi || '', deskripsi: statePosyandu.edit.form.deskripsi || '',
imageId: statePosyandu.edit.form.imageId || '', imageId: statePosyandu.edit.form.imageId || '',
jadwalPelayanan: statePosyandu.edit.form.jadwalPelayanan || '',
}); });
useEffect(() => { useEffect(() => {
@@ -40,6 +41,7 @@ function EditPosyandu() {
nomor: data.nomor || '', nomor: data.nomor || '',
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || '',
imageId: data.imageId || '', imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
}); });
if (data?.image?.link) { if (data?.image?.link) {
@@ -62,6 +64,7 @@ function EditPosyandu() {
nomor: formData.nomor, nomor: formData.nomor,
deskripsi: formData.deskripsi, deskripsi: formData.deskripsi,
imageId: formData.imageId, imageId: formData.imageId,
jadwalPelayanan: formData.jadwalPelayanan,
} }
if (file) { if (file) {
@@ -173,6 +176,16 @@ function EditPosyandu() {
}} }}
/> />
</Box> </Box>
<Box>
<Text fw={"bold"} fz={"sm"}>Jadwal Pelayanan</Text>
<EditEditor
value={formData.jadwalPelayanan}
onChange={(htmlContent) => {
setFormData({ ...formData, jadwalPelayanan: htmlContent });
statePosyandu.edit.form.jadwalPelayanan = htmlContent;
}}
/>
</Box>
<Group> <Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button>
</Group> </Group>

View File

@@ -63,6 +63,10 @@ function DetailPosyandu() {
<Text fz={"lg"} fw={"bold"}>Deskripsi Posyandu</Text> <Text fz={"lg"} fw={"bold"}>Deskripsi Posyandu</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: statePosyandu.findUnique.data.deskripsi }} /> <Text fz={"lg"} dangerouslySetInnerHTML={{ __html: statePosyandu.findUnique.data.deskripsi }} />
</Box> </Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Jadwal Pelayanan</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: statePosyandu.findUnique.data.jadwalPelayanan }} />
</Box>
<Box> <Box>
<Text fz={"lg"} fw={"bold"}>Gambar</Text> <Text fz={"lg"} fw={"bold"}>Gambar</Text>
<Image src={statePosyandu.findUnique.data.image?.link} alt="gambar" /> <Image src={statePosyandu.findUnique.data.image?.link} alt="gambar" />

View File

@@ -23,6 +23,7 @@ function CreatePosyandu() {
nomor: "", nomor: "",
deskripsi: "", deskripsi: "",
imageId: "", imageId: "",
jadwalPelayanan: "",
}; };
setFile(null); setFile(null);
@@ -147,6 +148,15 @@ function CreatePosyandu() {
}} }}
/> />
</Box> </Box>
<Box>
<Text fw={"bold"} fz={"sm"}>Jadwal Pelayanan</Text>
<CreateEditor
value={statePosyandu.create.form.jadwalPelayanan}
onChange={(htmlContent) => {
statePosyandu.create.form.jadwalPelayanan = htmlContent;
}}
/>
</Box>
<Group> <Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button>
</Group> </Group>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconSearch } from '@tabler/icons-react';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import JudulList from '../../_com/judulList'; import JudulList from '../../_com/judulList';
@@ -30,19 +30,21 @@ function ListPosyandu({ search }: { search: string }) {
const statePosyandu = useProxy(posyandustate) const statePosyandu = useProxy(posyandustate)
const router = useRouter(); const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = statePosyandu.findMany;
useShallowEffect(() => { useShallowEffect(() => {
statePosyandu.findMany.load() load(page, 10, search)
}, []) }, [page, search])
const filteredData = (statePosyandu.findMany.data || []).filter(item => { const filteredData = data || [];
const keyword = search.toLowerCase();
return (
item.name.toLowerCase().includes(keyword) ||
item.nomor.toString().toLowerCase().includes(keyword)
);
});
if (!statePosyandu.findMany.data) { if (loading || !data) {
return ( return (
<Box py={10}> <Box py={10}>
<Skeleton h={500} /> <Skeleton h={500} />
@@ -70,10 +72,20 @@ function ListPosyandu({ search }: { search: string }) {
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.nomor}</TableTd>
<TableTd> <TableTd>
<Text fz={"sm"} dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <Box w={100}>
<Text truncate="end" lineClamp={1} fz={"sm"}>{item.name}</Text>
</Box>
</TableTd>
<TableTd>
<Box w={100}>
<Text truncate="end" lineClamp={1} fz={"sm"}>{item.nomor}</Text>
</Box>
</TableTd>
<TableTd>
<Box w={100}>
<Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/posyandu/${item.id}`)}> <Button onClick={() => router.push(`/admin/kesehatan/posyandu/${item.id}`)}>
@@ -86,6 +98,15 @@ function ListPosyandu({ search }: { search: string }) {
</Table> </Table>
</Box> </Box>
</Paper> </Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)} // ini penting!
total={totalPages}
mt="md"
mb="md"
/>
</Center>
</Box> </Box>
); );
} }

View File

@@ -8,6 +8,7 @@ type FormCreate = Prisma.PosyanduGetPayload<{
nomor: true; nomor: true;
deskripsi: true; deskripsi: true;
imageId: true; imageId: true;
jadwalPelayanan: true;
}; };
}>; }>;
export default async function posyanduCreate(context: Context) { export default async function posyanduCreate(context: Context) {
@@ -19,6 +20,7 @@ export default async function posyanduCreate(context: Context) {
nomor: body.nomor, nomor: body.nomor,
deskripsi: body.deskripsi, deskripsi: body.deskripsi,
imageId: body.imageId, imageId: body.imageId,
jadwalPelayanan: body.jadwalPelayanan,
} }
}) })
return { return {

View File

@@ -1,26 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function posyanduFindMany() { async function posyanduFindMany(context: Context) {
try { // Ambil parameter dari query
const data = await prisma.posyandu.findMany({ const page = Number(context.query.page) || 1;
where: { const limit = Number(context.query.limit) || 10;
isActive: true, const search = (context.query.search as string) || '';
}, const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ deskripsi: { contains: search, mode: 'insensitive' } },
{ nomor: { contains: search, mode: 'insensitive' } },
{ jadwalPelayanan: { contains: search, mode: 'insensitive' } }
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.posyandu.findMany({
where,
include: { include: {
image: true, image: true,
} },
}) skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.posyandu.count({ where }),
]);
return { return {
success: true, success: true,
message: "Success fetch posyandu", message: "Berhasil ambil posyandu dengan pagination",
data, data,
} page,
} catch (error) { limit,
console.error("Find many error:", error); total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di posyanduFindMany paginated:", e);
return { return {
success: false, success: false,
message: "Failed fetch posyandu", message: "Gagal mengambil data posyandu",
} };
}
} }
} export default posyanduFindMany;

View File

@@ -15,6 +15,7 @@ const Posyandu = new Elysia({
nomor: t.String(), nomor: t.String(),
deskripsi: t.String(), deskripsi: t.String(),
imageId: t.String(), imageId: t.String(),
jadwalPelayanan: t.String(),
}) })
}) })
.get("/find-many", posyanduFindMany) .get("/find-many", posyanduFindMany)
@@ -35,6 +36,7 @@ const Posyandu = new Elysia({
nomor: t.String(), nomor: t.String(),
deskripsi: t.String(), deskripsi: t.String(),
imageId: t.String(), imageId: t.String(),
jadwalPelayanan: t.String(),
}) })
} }
) )

View File

@@ -11,6 +11,7 @@ type FormUpdate = Prisma.PosyanduGetPayload<{
nomor: true; nomor: true;
deskripsi: true; deskripsi: true;
imageId: true; imageId: true;
jadwalPelayanan: true;
} }
}> }>
@@ -24,6 +25,7 @@ export default async function posyanduUpdate(context: Context) {
nomor, nomor,
deskripsi, deskripsi,
imageId, imageId,
jadwalPelayanan,
} = body; } = body;
if(!id) { if(!id) {
@@ -79,6 +81,7 @@ export default async function posyanduUpdate(context: Context) {
nomor, nomor,
deskripsi, deskripsi,
imageId, imageId,
jadwalPelayanan,
} }
}) })

View File

@@ -1,36 +1,58 @@
'use client'
import colors from "@/con/colors"; import colors from "@/con/colors";
import { Stack, Box, Text, SimpleGrid, Paper, Center, Image, Flex, List, ListItem } from "@mantine/core"; import { Box, Center, Flex, Image, List, ListItem, Pagination, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, TextInput } from "@mantine/core";
import BackButton from "../../desa/layanan/_com/BackButto"; import BackButton from "../../desa/layanan/_com/BackButto";
// import { useTransitionRouter } from "next-view-transitions";
import posyandustate from "@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu";
import { useShallowEffect } from "@mantine/hooks";
import { useProxy } from "valtio/utils";
import { useState } from "react";
import { IconSearch } from "@tabler/icons-react";
const data = [
{
id: 1,
judul: 'Posyandu Banjar Bucu',
nomor: '082345678910',
image: '/api/img/posyandu.png'
},
{
id: 2,
judul: 'Posyandu Banjar Bucu',
nomor: '082345678910',
image: '/api/img/posyandu.png'
},
{
id: 3,
judul: 'Posyandu Banjar Bucu',
nomor: '082345678910',
image: '/api/img/posyandu.png'
}
]
export default function Page() { export default function Page() {
const state = useProxy(posyandustate)
// const router = useTransitionRouter()
const [search, setSearch] = useState("")
const {
data,
page,
totalPages,
loading,
load,
} = state.findMany;
useShallowEffect(() => {
load(page, 3, search)
}, [page, search])
if (loading || !data) {
return (
<Box py={10}>
<Skeleton h={500} />
</Box>
)
}
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
<Flex mt={10} justify={"space-between"} align={"center"}>
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Posyandu Darmasaba
</Text>
<TextInput
placeholder="Cari Posyandu"
radius="lg"
leftSection={<IconSearch size={20} />}
w={{ base: "25%", md: "30%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
</Box> </Box>
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Posyandu Darmasaba
</Text>
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "md", md: 100 }}>
<Stack gap={'lg'}> <Stack gap={'lg'}>
<SimpleGrid <SimpleGrid
@@ -39,49 +61,46 @@ export default function Page() {
base: 1, base: 1,
md: 3, md: 3,
}}> }}>
{data.map((v, k) => { {data?.map((v, k) => {
return ( return (
<Paper key={k} p={"xl"} bg={colors["white-trans-1"]}> <Paper key={k} p={"xl"} bg={colors["white-trans-1"]}>
<Stack gap={'xs'}> <Stack gap={'xs'}>
<Text c={colors["blue-button"]} fw={"bold"} fz={"h3"}> <Text c={colors["blue-button"]} fw={"bold"} fz={"h3"}>
{v.judul} {v.name}
</Text>
<Text fz={'h4'}>
{v.nomor}
</Text> </Text>
<Center> <Center>
<Image src={v.image} alt="" /> <Image src={v.image.link} alt="" />
</Center> </Center>
<Text fz={'h4'}> <Text fz={'h4'}>
Jadwal Pelayanan No.Telp : {v.nomor}
</Text>
<Text fz={'h4'}>
Setiap tanggal 5, Pukul 09:00 -
12:00 WITA
</Text> </Text>
<Box> <Box>
<Flex justify={'space-between'}> <Text fz={'h4'}>
<Text fz={'h4'}>Balita</Text> Jadwal Pelayanan
<Box> </Text>
<Text fz={'h4'}>Selasa minggu pertama</Text> <Text fz={'h4'} dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }} />
<Text fz={'h4'}>(09:00-11:00 WITA)</Text>
</Box>
</Flex>
</Box>
<Box>
<Flex justify={'space-between'}>
<Text fz={'h4'}>Lansia</Text>
<Box>
<Text fz={'h4'}>Selasa minggu pertama</Text>
<Text fz={'h4'}>(09:00-11:00 WITA)</Text>
</Box>
</Flex>
</Box> </Box>
<Spoiler
maxHeight={60} // tinggi maksimum sebelum di-collapse
showLabel="Read more"
hideLabel="Read less"
>
<Text fz={'h4'} dangerouslySetInnerHTML={{ __html: v.deskripsi }} />
</Spoiler>
</Stack> </Stack>
</Paper> </Paper>
) )
})} })}
</SimpleGrid> </SimpleGrid>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)} // ini penting!
total={totalPages}
mt="md"
mb="md"
/>
</Center>
<Text fz={'h4'} fw={"bold"}> <Text fz={'h4'} fw={"bold"}>
Pelayanan Posyandu Pelayanan Posyandu
</Text> </Text>