Sinkronisasi UI & API Admin - User Submenu Pasar Desa, Menu Ekonomi

This commit is contained in:
2025-08-20 17:01:20 +08:00
parent c22d865283
commit 90a6605efd
7 changed files with 347 additions and 196 deletions

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";
@@ -53,8 +54,8 @@ const pasarDesa = proxy({
}, },
}, },
findMany: { findMany: {
data: null as Array< data: null as
Prisma.PasarDesaGetPayload<{ | Prisma.PasarDesaGetPayload<{
include: { include: {
image: true; image: true;
KategoriToPasar: { KategoriToPasar: {
@@ -63,12 +64,37 @@ const pasarDesa = proxy({
}; };
}; };
}; };
}> }>[]
> | null, | null,
async load() { page: 1,
const res = await ApiFetch.api.ekonomi.pasardesa["find-many"].get(); totalPages: 1,
if (res.status === 200) { loading: false,
pasarDesa.findMany.data = res.data?.data ?? []; search: "",
load: async (page = 1, limit = 10, search = "", categoryId?: string) => {
pasarDesa.findMany.loading = true;
pasarDesa.findMany.page = page;
pasarDesa.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (categoryId) query.categoryId = categoryId;
const res = await ApiFetch.api.ekonomi.pasardesa["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
pasarDesa.findMany.data = res.data.data ?? [];
pasarDesa.findMany.totalPages = res.data.totalPages ?? 1;
} else {
pasarDesa.findMany.data = [];
pasarDesa.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch keamanan lingkungan paginated:", err);
pasarDesa.findMany.data = [];
pasarDesa.findMany.totalPages = 1;
} finally {
pasarDesa.findMany.loading = false;
} }
}, },
}, },
@@ -272,14 +298,41 @@ const kategoriProduk = proxy({
}, },
}, },
findMany: { findMany: {
data: null as Array<{ data: null as
id: string; | Prisma.KategoriProdukGetPayload<{
nama: string; omit: {
}> | null, isActive: true;
async load() { };
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get(); }>[]
if (res.status === 200) { | null,
kategoriProduk.findMany.data = res.data?.data ?? []; page: 1,
totalPages: 1,
loading: false,
search2: "",
load: async (page = 1, limit = 10, search2 = "") => {
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
kategoriProduk.findMany.page = page;
kategoriProduk.findMany.search2 = search2;
try {
const query: any = { page, limit };
if (search2) query.search2 = search2;
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriProduk.findMany.data = res.data.data ?? [];
kategoriProduk.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriProduk.findMany.data = [];
kategoriProduk.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kategori produk paginated:", err);
kategoriProduk.findMany.data = [];
kategoriProduk.findMany.totalPages = 1;
} finally {
kategoriProduk.findMany.loading = false;
} }
}, },
}, },

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, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr } from '@mantine/core'; import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconSearch, IconX } from '@tabler/icons-react'; import { IconEdit, IconSearch, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -13,31 +13,39 @@ import pasarDesaState from '../../../_state/ekonomi/pasar-desa/pasar-desa';
function PasarDesa() { function PasarDesa() {
const [search, setSearch] = useState("") const [search2, setSearch2] = useState("")
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Kategori Produk' title='Kategori Produk'
placeholder='pencarian' placeholder='pencarian'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search2}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch2(e.currentTarget.value)}
/> />
<ListPasarDesa search={search} /> <ListPasarDesa search2={search2} />
</Box> </Box>
); );
} }
function ListPasarDesa({ search }: { search: string }) { function ListPasarDesa({ search2 }: { search2: string }) {
const statePasar = useProxy(pasarDesaState.kategoriProduk) const statePasar = useProxy(pasarDesaState.kategoriProduk)
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
// const params = useParams() // const params = useParams()
const router = useRouter() const router = useRouter()
const {
data,
page,
totalPages,
loading,
load,
} = statePasar.findMany
useShallowEffect(() => { useShallowEffect(() => {
statePasar.findMany.load() load(page, 10, search2)
}, []) }, [page, search2])
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
@@ -47,14 +55,9 @@ function ListPasarDesa({ search }: { search: string }) {
} }
} }
const filteredData = (statePasar.findMany.data || []).filter(item => { const filteredData = data || []
const keyword = search.toLowerCase();
return (
item.nama.toLowerCase().includes(keyword)
);
});
if (!statePasar.findMany.data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton h={500} />
@@ -99,6 +102,14 @@ function ListPasarDesa({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Paper> </Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
my={"md"}
/>
</Center>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}

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, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr } from '@mantine/core'; import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr } from '@mantine/core';
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconSearch } from '@tabler/icons-react';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -30,21 +30,21 @@ function ListPasarDesa({ search }: { search: string }) {
const statePasar = useProxy(pasarDesaState.pasarDesa) const statePasar = useProxy(pasarDesaState.pasarDesa)
const router = useRouter(); const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = statePasar.findMany
useShallowEffect(() => { useShallowEffect(() => {
statePasar.findMany.load() load(page, 10, search)
}, []) }, [page, search])
const filteredData = (statePasar.findMany.data || []).filter(item => { const filteredData = data || []
const keyword = search.toLowerCase();
return (
item.nama.toLowerCase().includes(keyword) ||
item.harga.toString().toLowerCase().includes(keyword) ||
item.rating.toString().toLowerCase().includes(keyword) ||
item.alamatUsaha.toLowerCase().includes(keyword)
);
});
if (!statePasar.findMany.data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton h={500} />
@@ -86,6 +86,14 @@ function ListPasarDesa({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Paper> </Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
my={"md"}
/>
</Center>
</Box> </Box>
); );
} }

View File

@@ -1,26 +1,84 @@
/* 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 pasarDesaFindMany() { async function pasarDesaFindMany(context: Context) {
const data = await prisma.pasarDesa.findMany({ // Ambil parameter dari query
where: { const page = Number(context.query.page) || 1;
isActive: true, // Opsional filter const limit = Number(context.query.limit) || 10;
}, const search = (context.query.search as string) || '';
orderBy: { const categoryId = context.query.categoryId as string | undefined;
createdAt: "desc", const skip = (page - 1) * limit;
},
// Buat where clause
const where: any = { isActive: true };
// Tambahkan filter kategori (jika ada)
if (categoryId) {
where.KategoriToPasar = {
some: {
kategoriId: categoryId
}
};
}
// Tambahkan pencarian (jika ada)
if (search) {
where.AND = where.AND || [];
where.AND.push({
OR: [
{ nama: { contains: search, mode: 'insensitive' } },
{ alamatUsaha: { contains: search, mode: 'insensitive' } },
{
KategoriToPasar: {
some: {
kategori: {
nama: { contains: search, mode: 'insensitive' }
}
}
}
}
]
});
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.pasarDesa.findMany({
where,
include: { include: {
image: true, image: true,
KategoriToPasar: { KategoriToPasar: {
include: { include: {
kategori: true, kategori: true
}
}
}, },
}, skip,
}, take: limit,
}); orderBy: { createdAt: 'desc' },
}),
prisma.pasarDesa.count({ where }),
]);
return { return {
success: true, success: true,
message: "Berhasil mengambil semua data pasar desa", message: "Berhasil ambil pasar desa dengan pagination",
data, data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data pasar desa",
}; };
} }
}
export default pasarDesaFindMany;

View File

@@ -1,15 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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";
async function kategoriProdukFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search2 = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search2) {
where.OR = [
{ nama: { contains: search2, mode: 'insensitive' } },
{KategoriToPasar : {
some: {
kategori: {
nama: { contains: search2, mode: 'insensitive' }
}
}
}}
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.kategoriProduk.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.kategoriProduk.count({ where }),
]);
export default async function kategoriProdukFindMany() {
const data = await prisma.kategoriProduk.findMany();
return { return {
success: true, success: true,
data: data.map((item: any) => { message: "Berhasil ambil kategori produk dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return { return {
id: item.id, success: false,
nama: item.nama, message: "Gagal mengambil data kategori produk",
}
}),
}; };
} }
}
export default kategoriProdukFindMany;

View File

@@ -1,90 +1,76 @@
'use client' 'use client'
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Combobox, Flex, Image, InputBase, InputPlaceholder, Paper, SimpleGrid, Stack, Text, TextInput, useCombobox } from '@mantine/core'; import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconMapPinFilled, IconSearch, IconShoppingCartFilled, IconStarFilled } from '@tabler/icons-react'; import { IconMapPinFilled, IconSearch, IconShoppingCartFilled, IconStarFilled } from '@tabler/icons-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import BackButton from '../../desa/layanan/_com/BackButto'; import BackButton from '../../desa/layanan/_com/BackButto';
const groceries = [
'Makanan',
'Minuman',
'Pakaian',
'Alat Dapur',
'Alat Mandi',
'Furniture',
];
const dataBarang = [
{
id: 1,
image: '/api/img/semat.png',
judul: 'Semat Bambu / Semat Banten',
harga: 'Rp. 3000 / pcs',
bintang: '4.9',
alamat: 'Jl. Kecubung no.6'
},
{
id: 2,
image: '/api/img/kerupuk.png',
judul: 'Kerupuk Babi',
harga: 'Rp. 12000 / pcs',
bintang: '4.9',
alamat: 'Jl. Kenari no.7'
},
{
id: 3,
image: '/api/img/beras.png',
judul: 'beras Merah Organik',
harga: 'Rp. 40000 / 1 kg',
bintang: '4.9',
alamat: 'Jl. Mawar no.8'
},
{
id: 4,
image: '/api/img/genteng.png',
judul: 'Genteng',
harga: 'Rp. 3600 / pcs',
bintang: '4.9',
alamat: 'Jl. Kecubung no.16'
},
]
function Page() { function Page() {
const [search, setSearch] = useState('');
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
combobox.focusTarget();
setSearch('');
},
onDropdownOpen: () => {
combobox.focusSearchInput();
},
});
const [value, setValue] = useState<string | null>(null);
const options = groceries
.filter((item) => item.toLowerCase().includes(search.toLowerCase().trim()))
.map((item) => (
<Combobox.Option value={item} key={item}>
{item}
</Combobox.Option>
));
const router = useRouter() const router = useRouter()
const state = useProxy(pasarDesaState.pasarDesa)
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const {
data,
page,
loading,
totalPages,
load,
} = state.findMany
useShallowEffect(() => {
pasarDesaState.kategoriProduk.findMany.load()
}, [])
// Filter data based on selected category
const filteredData = selectedCategory
? data?.filter(item =>
item.KategoriToPasar?.some(kategori => kategori.kategoriId === selectedCategory)
)
: data;
useShallowEffect(() => {
load(page, 4, search, selectedCategory || undefined)
}, [page, search, selectedCategory])
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
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 />
</Box> </Box>
<Box> <Box>
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}> <Grid align='center' px={{ base: 'md', md: 100 }}>
<GridCol span={{ base: 12, md: 9 }}>
<Text fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Pasar Desa Pasar Desa
</Text> </Text>
<Text px={{ base: 20, md: 150 }} ta={"center"} fz={{ base: "h4", md: "h3" }} > </GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<TextInput
radius={"lg"}
placeholder='Cari Produk'
value={search}
onChange={(e) => setSearch(e.target.value)}
leftSection={<IconSearch size={20} />}
w={{ base: "50%", md: "100%" }}
/>
</GridCol>
</Grid>
<Text px={{ base: 'md', md: 100 }} ta={"justify"} fz={{ base: "h4", md: "h3" }} >
Pasar Desa Online merupakan Media Promosi yang bertujuan untuk membantu warga desa dalam memasarkan dan memperkenalkan produknya kepada masyarakat. Pasar Desa Online merupakan Media Promosi yang bertujuan untuk membantu warga desa dalam memasarkan dan memperkenalkan produknya kepada masyarakat.
</Text> </Text>
</Box> </Box>
@@ -98,48 +84,23 @@ function Page() {
}} }}
> >
<Box> <Box>
<Combobox <Select
store={combobox} placeholder="Pilih Kategori"
withinPortal={false} data={pasarDesaState.kategoriProduk.findMany.data?.map((v) => ({
onOptionSubmit={(val) => { value: v.id,
setValue(val); label: v.nama
combobox.closeDropdown(); })) || []}
}} value={selectedCategory}
> onChange={setSelectedCategory}
<Combobox.Target> clearable
<InputBase searchable
component="button" nothingFoundMessage="Tidak ada kategori ditemukan"
type="button" style={{ width: '100%' }}
pointer
rightSection={<Combobox.Chevron />}
onClick={() => combobox.toggleDropdown()}
rightSectionPointerEvents="none"
>
{value || <InputPlaceholder>Kategori</InputPlaceholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder="Search groceries"
/>
<Combobox.Options>
{options.length > 0 ? options : <Combobox.Empty>Nothing found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Box>
<Box>
<TextInput
placeholder='Cari Produk'
leftSection={<IconSearch size={20} />}
/> />
</Box> </Box>
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 4 }}> <SimpleGrid cols={{ base: 1, md: 4 }}>
{dataBarang.map((v, k) => { {filteredData?.map((v, k) => {
return ( return (
<Stack key={k}> <Stack key={k}>
<motion.div <motion.div
@@ -148,18 +109,25 @@ function Page() {
whileTap={{ scale: 0.8 }} whileTap={{ scale: 0.8 }}
> >
<Paper p={'lg'}> <Paper p={'lg'}>
<Image radius={'lg'} src={v.image} alt='' /> <Image
<Text py={10} fw={'bold'} fz={'lg'}>{v.judul}</Text> radius={'lg'}
<Text fz={'md'}>{v.harga}</Text> src={v.image?.link || '/placeholder-product.jpg'}
alt={v.nama}
h={200}
w='100%'
style={{ objectFit: 'cover' }}
/>
<Text py={10} fw={'bold'} fz={'lg'}>{v.nama}</Text>
<Text fz={'md'}>Rp {v.harga.toLocaleString('id-ID')}</Text>
<Flex py={10} gap={'md'}> <Flex py={10} gap={'md'}>
<IconStarFilled size={20} color='#EBCB09' /> <IconStarFilled size={20} color='#EBCB09' />
<Text fz={'sm'} ml={2}>{v.bintang}</Text> <Text fz={'sm'} ml={2}>{v.rating}</Text>
</Flex> </Flex>
<Flex justify={'space-between'} align={'center'}> <Flex justify={'space-between'} align={'center'}>
<Box> <Box>
<Flex gap={'md'} align={'center'}> <Flex gap={'md'} align={'center'}>
<IconMapPinFilled size={20} color='red' /> <IconMapPinFilled size={20} color='red' />
<Text fz={'sm'} ml={2}>{v.alamat}</Text> <Text fz={'sm'} ml={2}>{v.alamatUsaha}</Text>
</Flex> </Flex>
</Box> </Box>
<IconShoppingCartFilled size={20} color={colors['blue-button']} /> <IconShoppingCartFilled size={20} color={colors['blue-button']} />
@@ -170,6 +138,14 @@ function Page() {
) )
})} })}
</SimpleGrid> </SimpleGrid>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)} // ini penting!
total={totalPages}
my="md"
/>
</Center>
</Stack> </Stack>
</Box> </Box>
</Stack> </Stack>