feat(public-ui): unify Pasar Desa and UMKM into a single tabbed page
This commit is contained in:
@@ -3,14 +3,11 @@
|
|||||||
## Accomplishments
|
## Accomplishments
|
||||||
- Successfully migrated the database to include `Umkm`, `ProdukUmkm`, and `PenjualanProduk` tables.
|
- Successfully migrated the database to include `Umkm`, `ProdukUmkm`, and `PenjualanProduk` tables.
|
||||||
- Implemented a complete set of CRUD API endpoints for UMKM, Products, and Sales.
|
- Implemented a complete set of CRUD API endpoints for UMKM, Products, and Sales.
|
||||||
- Developed a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
|
- Implemented a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
|
||||||
- Integrated the new module into the existing `ekonomi` router.
|
- Integrated the new module into the existing `ekonomi` router.
|
||||||
- Implemented the Admin UI with a modern tab-based layout.
|
- Implemented the Admin UI with a modern tab-based layout for complete business management.
|
||||||
- Created four main admin pages: Dashboard, Data UMKM, Produk, and Penjualan.
|
- Unified the Public UI by integrating UMKM data into a single "Pasar Desa & UMKM" hub with tabbed navigation.
|
||||||
- Registered the new UMKM module in the Admin Navigation Menu for all roles.
|
- Registered the unified page in the Website Navbar, reducing menu clutter.
|
||||||
- Implemented the Public UI for citizens to browse local businesses.
|
|
||||||
- Created three public pages: Direktori UMKM, UMKM Detail, and Katalog Produk.
|
|
||||||
- Registered the public UMKM pages in the main Website Navbar under the Ekonomi section.
|
|
||||||
- Verified the implementation with `tsc` and `bun run build`.
|
- Verified the implementation with `tsc` and `bun run build`.
|
||||||
|
|
||||||
## Files Created/Modified
|
## Files Created/Modified
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "desa-darmasaba",
|
"name": "desa-darmasaba",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -1,50 +1,27 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
|
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
|
||||||
|
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from '@mantine/core';
|
import {
|
||||||
|
Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper,
|
||||||
|
Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title,
|
||||||
|
Tabs, Badge, Card, Group
|
||||||
|
} from '@mantine/core';
|
||||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconBrandWhatsapp, IconMapPinFilled, IconSearch, IconStarFilled } from '@tabler/icons-react';
|
import {
|
||||||
|
IconBrandWhatsapp, IconMapPinFilled, IconSearch,
|
||||||
|
IconStarFilled, IconBuildingStore, IconTag, IconUser,
|
||||||
|
IconShoppingBag, IconPackage
|
||||||
|
} 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 { useProxy } from 'valtio/utils';
|
||||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||||
|
|
||||||
function Page() {
|
function Page() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const state = useProxy(pasarDesaState.pasarDesa)
|
const [activeTab, setActiveTab] = useState<string | null>('produk-pasar');
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
page,
|
|
||||||
loading,
|
|
||||||
totalPages,
|
|
||||||
load,
|
|
||||||
} = state.findMany
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
pasarDesaState.kategoriProduk.findManyAll.load()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredData = selectedCategory
|
|
||||||
? data?.filter(item =>
|
|
||||||
item.KategoriToPasar?.some(kategori => kategori.kategoriId === selectedCategory)
|
|
||||||
)
|
|
||||||
: data;
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
load(page, 4, debouncedSearch, selectedCategory || undefined)
|
|
||||||
}, [page, debouncedSearch, 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">
|
||||||
@@ -52,137 +29,283 @@ function Page() {
|
|||||||
<BackButton />
|
<BackButton />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15} mb="md">
|
||||||
<GridCol span={{ base: 12, md: 9 }}>
|
Pasar Desa & UMKM Darmasaba
|
||||||
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15}>
|
</Title>
|
||||||
Pasar Desa
|
<Text ta="justify" fz={{ base: 'sm', md: 'md' }} lh={1.5} c="black" mb="xl">
|
||||||
</Title>
|
Pusat informasi produk lokal dan direktori usaha warga Desa Darmasaba.
|
||||||
</GridCol>
|
Temukan berbagai produk unggulan dan dukung ekonomi pengusaha desa kami.
|
||||||
<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={"100%"}
|
|
||||||
/>
|
|
||||||
</GridCol>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
px={{ base: 'md', md: 100 }}
|
|
||||||
pt={20}
|
|
||||||
ta="justify"
|
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
Pasar Desa Online adalah media promosi untuk membantu warga memasarkan dan memperkenalkan produk mereka.
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
<Tabs value={activeTab} onChange={setActiveTab} color="blue" variant="pills" radius="md">
|
||||||
<Stack gap="lg">
|
<Tabs.List mb="xl">
|
||||||
<SimpleGrid pb={30} cols={{ base: 1, md: 2 }}>
|
<Tabs.Tab value="produk-pasar" leftSection={<IconShoppingBag size={18} />}>
|
||||||
<Box>
|
Produk Pasar Desa
|
||||||
<Select
|
</Tabs.Tab>
|
||||||
placeholder="Pilih Kategori"
|
<Tabs.Tab value="produk-umkm" leftSection={<IconPackage size={18} />}>
|
||||||
data={pasarDesaState.kategoriProduk.findManyAll.data?.map((v) => ({
|
Produk UMKM
|
||||||
value: v.id,
|
</Tabs.Tab>
|
||||||
label: v.nama
|
<Tabs.Tab value="direktori-umkm" leftSection={<IconBuildingStore size={18} />}>
|
||||||
})) || []}
|
Direktori UMKM
|
||||||
value={selectedCategory}
|
</Tabs.Tab>
|
||||||
onChange={setSelectedCategory}
|
</Tabs.List>
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
nothingFoundMessage="Tidak ada kategori ditemukan"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
|
||||||
c="black"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 4 }}>
|
<Tabs.Panel value="produk-pasar">
|
||||||
{filteredData?.map((v, k) => (
|
<TabProdukPasar router={router} />
|
||||||
<Stack key={k}>
|
</Tabs.Panel>
|
||||||
<motion.div
|
|
||||||
onClick={() => router.push(`/darmasaba/ekonomi/pasar-desa/${v.id}`)}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.8 }}
|
|
||||||
>
|
|
||||||
<Paper p="lg">
|
|
||||||
<Image
|
|
||||||
radius="lg"
|
|
||||||
src={v.image?.link || '/placeholder-product.jpg'}
|
|
||||||
alt={v.nama}
|
|
||||||
h={200}
|
|
||||||
w="100%"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
py="sm"
|
|
||||||
fw="bold"
|
|
||||||
fz={{ base: 'md', md: 'lg' }}
|
|
||||||
lh={{ base: 1.3, md: 1.25 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
{v.nama}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
Rp {v.harga.toLocaleString('id-ID')}
|
|
||||||
</Text>
|
|
||||||
<Flex py="sm" gap="md" align="center">
|
|
||||||
<IconStarFilled size={20} color="#EBCB09" />
|
|
||||||
<Text
|
|
||||||
fz={{ base: 'xs', md: 'sm' }}
|
|
||||||
lh={{ base: 1.4, md: 1.45 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
{v.rating}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex justify="space-between" align="center">
|
|
||||||
<Box>
|
|
||||||
<Flex gap="md" align="center">
|
|
||||||
<IconMapPinFilled size={20} color="red" />
|
|
||||||
<Text
|
|
||||||
fz={{ base: 'xs', md: 'sm' }}
|
|
||||||
lh={{ base: 1.4, md: 1.45 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
{v.alamatUsaha}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
<IconBrandWhatsapp size={20} color={colors['blue-button']} />
|
|
||||||
</Flex>
|
|
||||||
</Paper>
|
|
||||||
</motion.div>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Center>
|
<Tabs.Panel value="produk-umkm">
|
||||||
<Pagination
|
<TabProdukUmkm router={router} />
|
||||||
value={page}
|
</Tabs.Panel>
|
||||||
onChange={(newPage) => load(newPage)}
|
|
||||||
total={totalPages}
|
<Tabs.Panel value="direktori-umkm">
|
||||||
my="md"
|
<TabDirektoriUmkm router={router} />
|
||||||
/>
|
</Tabs.Panel>
|
||||||
</Center>
|
</Tabs>
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TAB COMPONENTS ---
|
||||||
|
|
||||||
|
function TabProdukPasar({ router }: { router: any }) {
|
||||||
|
const state = useProxy(pasarDesaState.pasarDesa);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, page, loading, totalPages, load } = state.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
pasarDesaState.kategoriProduk.findManyAll.load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 8, debouncedSearch, selectedCategory || undefined);
|
||||||
|
}, [page, debouncedSearch, selectedCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Grid gutter="md" align="flex-end">
|
||||||
|
<GridCol span={{ base: 12, md: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
label="Cari Produk Pasar"
|
||||||
|
placeholder="Ketik nama produk..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch size={18} />}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, md: 4 }}>
|
||||||
|
<Select
|
||||||
|
label="Kategori"
|
||||||
|
placeholder="Pilih Kategori"
|
||||||
|
data={pasarDesaState.kategoriProduk.findManyAll.data?.map((v) => ({
|
||||||
|
value: v.id, label: v.nama
|
||||||
|
})) || []}
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton h={400} radius="md" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
{data?.map((v, k) => (
|
||||||
|
<motion.div
|
||||||
|
key={k}
|
||||||
|
onClick={() => router.push(`/darmasaba/ekonomi/pasar-desa/${v.id}`)}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
>
|
||||||
|
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer' }}>
|
||||||
|
<Image
|
||||||
|
radius="md"
|
||||||
|
src={v.image?.link || '/no-image.jpg'}
|
||||||
|
alt={v.nama}
|
||||||
|
h={180}
|
||||||
|
w="100%"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Text mt="md" fw="bold" fz="lg" lineClamp={1}>{v.nama}</Text>
|
||||||
|
<Text c="blue" fw={700} fz="md">Rp {v.harga.toLocaleString('id-ID')}</Text>
|
||||||
|
<Flex py="xs" gap="xs" align="center">
|
||||||
|
<IconStarFilled size={16} color="#EBCB09" />
|
||||||
|
<Text size="sm">{v.rating}</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Flex gap={5} align="center">
|
||||||
|
<IconMapPinFilled size={16} color="red" />
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>{v.alamatUsaha}</Text>
|
||||||
|
</Flex>
|
||||||
|
<IconBrandWhatsapp size={18} color="green" />
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Center>
|
||||||
|
<Pagination total={totalPages} value={page} onChange={(p) => load(p)} radius="md" />
|
||||||
|
</Center>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabProdukUmkm({ router }: { router: any }) {
|
||||||
|
const state = useProxy(umkmState.produk);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, page, loading, totalPages, load } = state.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
umkmState.kategoriProduk.findManyAll.load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 8, debouncedSearch, undefined, selectedCategory || undefined);
|
||||||
|
}, [page, debouncedSearch, selectedCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Grid gutter="md" align="flex-end">
|
||||||
|
<GridCol span={{ base: 12, md: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
label="Cari Produk UMKM"
|
||||||
|
placeholder="Ketik nama produk..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch size={18} />}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, md: 4 }}>
|
||||||
|
<Select
|
||||||
|
label="Kategori"
|
||||||
|
placeholder="Pilih Kategori"
|
||||||
|
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
|
||||||
|
value: v.id, label: v.nama
|
||||||
|
})) || []}
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton h={400} radius="md" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
{data?.map((v, k) => (
|
||||||
|
<motion.div key={k} whileHover={{ scale: 1.03 }}>
|
||||||
|
<Card shadow="xs" padding="md" radius="md" withBorder>
|
||||||
|
<Card.Section>
|
||||||
|
<Image
|
||||||
|
src={v.image?.link || '/no-image.jpg'}
|
||||||
|
height={160}
|
||||||
|
alt={v.nama}
|
||||||
|
/>
|
||||||
|
</Card.Section>
|
||||||
|
<Stack mt="md" gap="xs">
|
||||||
|
<Badge color={v.stok > 0 ? 'teal' : 'red'}>
|
||||||
|
{v.stok > 0 ? 'Tersedia' : 'Habis'}
|
||||||
|
</Badge>
|
||||||
|
<Text fw={700} fz="md" lineClamp={1}>{v.nama}</Text>
|
||||||
|
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`)}>
|
||||||
|
{v.umkm?.nama}
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" fw={800} c="orange">Rp {v.harga.toLocaleString('id-ID')}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Center>
|
||||||
|
<Pagination total={totalPages} value={page} onChange={(p) => load(p)} radius="md" />
|
||||||
|
</Center>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabDirektoriUmkm({ router }: { router: any }) {
|
||||||
|
const state = useProxy(umkmState.umkm);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
|
const { data, page, loading, totalPages, load } = state.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 8, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<TextInput
|
||||||
|
label="Cari UMKM"
|
||||||
|
placeholder="Nama UMKM atau Pemilik..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftSection={<IconSearch size={18} />}
|
||||||
|
radius="md"
|
||||||
|
maw={500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton h={400} radius="md" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
{data?.map((v, k) => (
|
||||||
|
<motion.div
|
||||||
|
key={k}
|
||||||
|
onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.id}`)}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
>
|
||||||
|
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer', height: '100%' }}>
|
||||||
|
<Image
|
||||||
|
radius="md"
|
||||||
|
src={v.image?.link || '/no-image.jpg'}
|
||||||
|
alt={v.nama}
|
||||||
|
h={160}
|
||||||
|
w="100%"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Badge mt="md" variant="light">{v.kategori?.nama}</Badge>
|
||||||
|
<Text mt="xs" fw="bold" fz="md" lineClamp={1}>{v.nama}</Text>
|
||||||
|
<Flex mt={8} gap="xs" align="center">
|
||||||
|
<IconUser size={14} color="gray" />
|
||||||
|
<Text fz="xs" c="dimmed">{v.pemilik}</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex mt={4} gap="xs" align="center">
|
||||||
|
<IconMapPinFilled size={14} color="red" />
|
||||||
|
<Text fz="xs" c="dimmed" lineClamp={1}>{v.alamat || 'Darmasaba'}</Text>
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Center>
|
||||||
|
<Pagination total={totalPages} value={page} onChange={(p) => load(p)} radius="md" />
|
||||||
|
</Center>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
|
||||||
import colors from '@/con/colors';
|
|
||||||
import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title, Badge } from '@mantine/core';
|
|
||||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
|
||||||
import { IconMapPinFilled, IconSearch, IconUser } from '@tabler/icons-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useProxy } from 'valtio/utils';
|
|
||||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
|
||||||
|
|
||||||
function Page() {
|
|
||||||
const router = useRouter()
|
|
||||||
const state = useProxy(umkmState.umkm)
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data, page, loading, totalPages, load } = state.findMany
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
umkmState.kategoriProduk.findManyAll.load()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
load(page, 8, debouncedSearch, selectedCategory || undefined)
|
|
||||||
}, [page, debouncedSearch, selectedCategory])
|
|
||||||
|
|
||||||
if (loading || !data) {
|
|
||||||
return (
|
|
||||||
<Stack py={10}>
|
|
||||||
<Skeleton h={500} />
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
|
||||||
<GridCol span={{ base: 12, md: 9 }}>
|
|
||||||
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15}>
|
|
||||||
Direktori UMKM Desa Darmasaba
|
|
||||||
</Title>
|
|
||||||
</GridCol>
|
|
||||||
<GridCol span={{ base: 12, md: 3 }}>
|
|
||||||
<TextInput
|
|
||||||
radius="lg"
|
|
||||||
placeholder="Cari UMKM"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
leftSection={<IconSearch size={20} />}
|
|
||||||
w={"100%"}
|
|
||||||
/>
|
|
||||||
</GridCol>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
px={{ base: 'md', md: 100 }}
|
|
||||||
pt={20}
|
|
||||||
ta="justify"
|
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
Daftar Usaha Mikro, Kecil, dan Menengah yang ada di wilayah Desa Darmasaba.
|
|
||||||
Mendukung pertumbuhan ekonomi lokal melalui pemberdayaan pengusaha desa.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<SimpleGrid pb={10} cols={{ base: 1, md: 2 }}>
|
|
||||||
<Box>
|
|
||||||
<Select
|
|
||||||
placeholder="Filter Kategori"
|
|
||||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
|
|
||||||
value: v.id,
|
|
||||||
label: v.nama
|
|
||||||
})) || []}
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={setSelectedCategory}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
nothingFoundMessage="Tidak ada kategori ditemukan"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
|
||||||
{data?.map((v, k) => (
|
|
||||||
<motion.div
|
|
||||||
key={k}
|
|
||||||
onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.id}`)}
|
|
||||||
whileHover={{ scale: 1.03 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer', height: '100%' }}>
|
|
||||||
<Image
|
|
||||||
radius="md"
|
|
||||||
src={v.image?.link || '/no-image.jpg'}
|
|
||||||
alt={v.nama}
|
|
||||||
h={180}
|
|
||||||
w="100%"
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
fallbackSrc="/no-image.jpg"
|
|
||||||
/>
|
|
||||||
<Badge mt="md" variant="light" color="blue">
|
|
||||||
{v.kategori?.nama}
|
|
||||||
</Badge>
|
|
||||||
<Text mt="xs" fw="bold" fz="lg" lh={1.2} lineClamp={1}>
|
|
||||||
{v.nama}
|
|
||||||
</Text>
|
|
||||||
<Flex mt={8} gap="xs" align="center">
|
|
||||||
<IconUser size={16} color="gray" />
|
|
||||||
<Text fz="sm" c="dimmed">{v.pemilik}</Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex mt={4} gap="xs" align="center">
|
|
||||||
<IconMapPinFilled size={16} color="red" />
|
|
||||||
<Text fz="sm" c="dimmed" lineClamp={1}>{v.alamat || 'Darmasaba'}</Text>
|
|
||||||
</Flex>
|
|
||||||
</Paper>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{data.length === 0 && (
|
|
||||||
<Center py={50}>
|
|
||||||
<Text c="dimmed">Tidak ada UMKM ditemukan</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Pagination
|
|
||||||
value={page}
|
|
||||||
onChange={(newPage) => load(newPage)}
|
|
||||||
total={totalPages}
|
|
||||||
my="md"
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
|
|
||||||
import colors from '@/con/colors';
|
|
||||||
import { Box, Center, Flex, Grid, GridCol, Image, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title, Badge, Card, Group } from '@mantine/core';
|
|
||||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
|
||||||
import { IconBuildingStore, IconSearch, IconTag } from '@tabler/icons-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useProxy } from 'valtio/utils';
|
|
||||||
import BackButton from '../../../desa/layanan/_com/BackButto';
|
|
||||||
|
|
||||||
function Page() {
|
|
||||||
const router = useRouter()
|
|
||||||
const state = useProxy(umkmState.produk)
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
||||||
const [selectedUmkm, setSelectedUmkm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data, page, loading, totalPages, load } = state.findMany
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
umkmState.kategoriProduk.findManyAll.load()
|
|
||||||
// Load all UMKM for filter
|
|
||||||
fetch('/api/ekonomi/umkm/find-many-all')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(res => {
|
|
||||||
if (res.success) {
|
|
||||||
(umkmState.umkm as any).allList = res.data;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const allUmkm = useProxy(umkmState.umkm as any).allList || [];
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
load(page, 12, debouncedSearch, selectedUmkm || undefined, selectedCategory || undefined)
|
|
||||||
}, [page, debouncedSearch, selectedUmkm, selectedCategory])
|
|
||||||
|
|
||||||
if (loading || !data) {
|
|
||||||
return (
|
|
||||||
<Stack py={10}>
|
|
||||||
<Skeleton h={500} />
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
|
||||||
<GridCol span={{ base: 12, md: 9 }}>
|
|
||||||
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15}>
|
|
||||||
Katalog Produk UMKM
|
|
||||||
</Title>
|
|
||||||
</GridCol>
|
|
||||||
<GridCol span={{ base: 12, md: 3 }}>
|
|
||||||
<TextInput
|
|
||||||
radius="lg"
|
|
||||||
placeholder="Cari Produk..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
||||||
leftSection={<IconSearch size={20} />}
|
|
||||||
w={"100%"}
|
|
||||||
/>
|
|
||||||
</GridCol>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
px={{ base: 'md', md: 100 }}
|
|
||||||
pt={20}
|
|
||||||
ta="justify"
|
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
|
||||||
lh={{ base: 1.5, md: 1.55 }}
|
|
||||||
c="black"
|
|
||||||
>
|
|
||||||
Temukan berbagai produk unggulan dari pelaku UMKM di Desa Darmasaba.
|
|
||||||
Mulai dari kuliner, kerajinan tangan, hingga jasa tersedia untuk memenuhi kebutuhan Anda.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
<Select
|
|
||||||
placeholder="Filter Berdasarkan UMKM"
|
|
||||||
data={allUmkm.map((v: any) => ({ value: v.id, label: v.nama }))}
|
|
||||||
value={selectedUmkm}
|
|
||||||
onChange={setSelectedUmkm}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
nothingFoundMessage="UMKM tidak ditemukan"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="Filter Kategori"
|
|
||||||
data={umkmState.kategoriProduk.findManyAll.data?.map((v) => ({
|
|
||||||
value: v.id,
|
|
||||||
label: v.nama
|
|
||||||
})) || []}
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={setSelectedCategory}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
nothingFoundMessage="Kategori tidak ditemukan"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
|
||||||
{data?.map((v, k) => (
|
|
||||||
<motion.div
|
|
||||||
key={k}
|
|
||||||
whileHover={{ y: -5 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ height: '100%' }}>
|
|
||||||
<Card.Section>
|
|
||||||
<Image
|
|
||||||
src={v.image?.link || '/no-image.jpg'}
|
|
||||||
height={180}
|
|
||||||
alt={v.nama}
|
|
||||||
fallbackSrc="/no-image.jpg"
|
|
||||||
/>
|
|
||||||
</Card.Section>
|
|
||||||
|
|
||||||
<Stack justify="space-between" mt="md" h="100%">
|
|
||||||
<Box>
|
|
||||||
<Group justify="space-between" mb="xs">
|
|
||||||
<Badge color={v.stok > 0 ? 'teal' : 'red'} variant="light">
|
|
||||||
{v.stok > 0 ? 'Tersedia' : 'Habis'}
|
|
||||||
</Badge>
|
|
||||||
<Text size="xs" c="dimmed">Stok: {v.stok}</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Text fw={700} fz="lg" lineClamp={1}>{v.nama}</Text>
|
|
||||||
|
|
||||||
<Flex align="center" gap={5} mt={5}>
|
|
||||||
<IconBuildingStore size={14} color="gray" />
|
|
||||||
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`)}>
|
|
||||||
{v.umkm?.nama}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex align="center" gap={5} mt={2}>
|
|
||||||
<IconTag size={14} color="gray" />
|
|
||||||
<Text size="xs" c="dimmed">{v.umkm?.kategori?.nama}</Text>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box mt="md">
|
|
||||||
<Text fz="xl" fw={800} c="orange">
|
|
||||||
Rp {v.harga.toLocaleString('id-ID')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{data.length === 0 && (
|
|
||||||
<Center py={80}>
|
|
||||||
<Stack align="center">
|
|
||||||
<Text c="dimmed" fz="lg">Produk tidak ditemukan</Text>
|
|
||||||
<Text size="sm" c="dimmed">Coba gunakan kata kunci atau filter lain</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Pagination
|
|
||||||
value={page}
|
|
||||||
onChange={(newPage) => load(newPage)}
|
|
||||||
total={totalPages}
|
|
||||||
my="md"
|
|
||||||
color="blue"
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -182,51 +182,41 @@ const navbarListMenu = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.3",
|
id: "5.3",
|
||||||
name: "Direktori UMKM",
|
|
||||||
href: "/darmasaba/ekonomi/umkm"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.4",
|
|
||||||
name: "Produk UMKM",
|
|
||||||
href: "/darmasaba/ekonomi/umkm/produk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.5",
|
|
||||||
name: "Struktur Organisasi dan SK Pengurus BUMDesa",
|
name: "Struktur Organisasi dan SK Pengurus BUMDesa",
|
||||||
href: "/darmasaba/ekonomi/Struktur-Organisasi-Dan-Sk-Pengurus-BumDes"
|
href: "/darmasaba/ekonomi/Struktur-Organisasi-Dan-Sk-Pengurus-BumDes"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.6",
|
id: "5.4",
|
||||||
name: "PADesa (Pendapatan Asli Desa)",
|
name: "PADesa (Pendapatan Asli Desa)",
|
||||||
href: "/darmasaba/ekonomi/PADesa-pendapatan-asli-desa"
|
href: "/darmasaba/ekonomi/PADesa-pendapatan-asli-desa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.7",
|
id: "5.5",
|
||||||
name: "Jumlah Pengangguran",
|
name: "Jumlah Pengangguran",
|
||||||
href: "/darmasaba/ekonomi/jumlah-pengangguran"
|
href: "/darmasaba/ekonomi/jumlah-pengangguran"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.8",
|
id: "5.6",
|
||||||
name: "Jumlah penduduk usia kerja yang menganggur",
|
name: "Jumlah penduduk usia kerja yang menganggur",
|
||||||
href: "/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur"
|
href: "/darmasaba/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.9",
|
id: "5.7",
|
||||||
name: "Jumlah Penduduk Miskin",
|
name: "Jumlah Penduduk Miskin",
|
||||||
href: "/darmasaba/ekonomi/jumlah-penduduk-miskin"
|
href: "/darmasaba/ekonomi/jumlah-penduduk-miskin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.10",
|
id: "5.8",
|
||||||
name: "Program Kemiskinan",
|
name: "Program Kemiskinan",
|
||||||
href: "/darmasaba/ekonomi/program-kemiskinan"
|
href: "/darmasaba/ekonomi/program-kemiskinan"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.11",
|
id: "5.9",
|
||||||
name: "Sektor Unggulan Desa",
|
name: "Sektor Unggulan Desa",
|
||||||
href: "/darmasaba/ekonomi/sektor-unggulan-desa"
|
href: "/darmasaba/ekonomi/sektor-unggulan-desa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5.12",
|
id: "5.10",
|
||||||
name: "Demografi Pekerjaan",
|
name: "Demografi Pekerjaan",
|
||||||
href: "/darmasaba/ekonomi/demografi-pekerjaan"
|
href: "/darmasaba/ekonomi/demografi-pekerjaan"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user