API & UI Menu Inovasi, SubMenu Layanan Online Desa

This commit is contained in:
2025-07-17 15:31:10 +08:00
parent cde6c91cd4
commit 49a1084099
14 changed files with 385 additions and 51 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -53,6 +53,7 @@
"framer-motion": "^12.23.5",
"get-port": "^7.1.0",
"jotai": "^2.12.3",
"list": "^2.0.19",
"lodash": "^4.17.21",
"motion": "^12.4.1",
"nanoid": "^5.1.5",

View File

@@ -263,6 +263,59 @@ const berita = proxy({
berita.edit.form = { ...defaultForm };
},
},
findFirst: {
data: null as Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}> | null,
loading: false,
async load() {
this.loading = true;
try {
const res = await ApiFetch.api.desa.berita["find-first"].get();
if (res.status === 200 && res.data?.success) {
// Add type assertion to ensure type safety
berita.findFirst.data = res.data.data as Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}> | null;
}
} catch (err) {
console.error("Gagal fetch berita terbaru:", err);
} finally {
this.loading = false;
}
},
},
findRecent: {
data: [] as Prisma.BeritaGetPayload<{
include: {
image: true;
kategoriBerita: true;
};
}>[],
loading: false,
async load() {
try {
this.loading = true;
const res = await ApiFetch.api.desa.berita["find-recent"].get();
if (res.status === 200 && res.data?.success) {
this.data = res.data.data ?? [];
}
} catch (error) {
console.error("Gagal fetch berita recent:", error);
} finally {
this.loading = false;
}
},
}
});

View File

@@ -68,11 +68,11 @@ const pengumuman = proxy({
},
findMany: {
data: null as
| Prisma.PengumumanGetPayload<{
include: {
| Prisma.PengumumanGetPayload<{
include: {
CategoryPengumuman: true;
}
}>[]
};
}>[]
| null,
async load() {
const res = await ApiFetch.api.desa.pengumuman["find-many"].get();
@@ -82,30 +82,28 @@ const pengumuman = proxy({
}
},
},
// findUnique: {
// data: null as
// | Prisma.PengumumanGetPayload<{
// include: {
// CategoryPengumuman: true;
// }
// }>
// | null,
// async load(id: string) {
// try {
// const res = await fetch(`/api/desa/pengumuman/${id}`);
// if (res.ok) {
// const data = await res.json();
// pengumuman.findUnique.data = data.data ?? null;
// } else {
// console.error('Failed to fetch pengumuman:', res.statusText);
// pengumuman.findUnique.data = null;
// }
// } catch (error) {
// console.error('Error fetching pengumuman:', error);
// pengumuman.findUnique.data = null;
// }
// },
// },
findUnique: {
data: null as Prisma.PengumumanGetPayload<{
include: {
CategoryPengumuman: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/desa/pengumuman/${id}`);
if (res.ok) {
const data = await res.json();
pengumuman.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch pengumuman:", res.statusText);
pengumuman.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching pengumuman:", error);
pengumuman.findUnique.data = null;
}
},
},
delete: {
loading: false,
async byId(id: string) {
@@ -237,6 +235,55 @@ const pengumuman = proxy({
}
},
},
findFirst: {
data: null as Prisma.PengumumanGetPayload<{
include: {
CategoryPengumuman: true;
};
}> | null,
loading: false,
async load() {
this.loading = true;
try {
const res = await ApiFetch.api.desa.pengumuman["find-first"].get();
if (res.status === 200 && res.data?.success) {
// Add type assertion to ensure type safety
pengumuman.findFirst.data = res.data
.data as Prisma.PengumumanGetPayload<{
include: {
CategoryPengumuman: true;
};
}> | null;
}
} catch (err) {
console.error("Gagal fetch pengumuman terbaru:", err);
} finally {
this.loading = false;
}
},
},
findRecent: {
data: [] as Prisma.PengumumanGetPayload<{
include: {
CategoryPengumuman: true;
};
}>[],
loading: false,
async load() {
try {
this.loading = true;
const res = await ApiFetch.api.desa.pengumuman["find-recent"].get();
if (res.status === 200 && res.data?.success) {
this.data = res.data.data ?? [];
}
} catch (error) {
console.error("Gagal fetch pengumuman recent:", error);
} finally {
this.loading = false;
}
},
},
});
const stateDesaPengumuman = proxy({

View File

@@ -0,0 +1,30 @@
import prisma from '@/lib/prisma';
export default async function beritaFindFirst() {
try {
const result = await prisma.berita.findFirst({
where: {
isActive: true, // opsional kalau kamu punya field ini
},
orderBy: {
createdAt: 'desc', // ambil yang paling terbaru
},
include: {
image: true,
kategoriBerita: true,
}
});
return {
success: true,
message: 'Berhasil ambil berita terbaru',
data: result,
};
} catch (error) {
console.error('[findFirstBerita] Error:', error);
return {
success: false,
message: 'Gagal ambil berita terbaru',
};
}
}

View File

@@ -0,0 +1,19 @@
import prisma from "@/lib/prisma";
export default async function findRecentBerita() {
const result = await prisma.berita.findMany({
orderBy: {
createdAt: "desc",
},
take: 4, // ambil 4 data terbaru
include: {
image: true,
kategoriBerita: true,
},
});
return {
success: true,
data: result,
};
}

View File

@@ -5,6 +5,8 @@ import beritaCreate from "./create";
import beritaDelete from "./del";
import beritaUpdate from "./updt";
import findBeritaById from "./find-by-id";
import beritaFindFirst from "./findFirst";
import findRecentBerita from "./findRecent";
const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
.get("/category/find-many", kategoriBeritaFindMany)
@@ -22,6 +24,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
kategoriBeritaId: t.Union([t.String(), t.Null()]),
}),
})
.get("/find-first", beritaFindFirst)
.get("/find-recent", findRecentBerita)
.delete("/delete/:id", beritaDelete)
.put(
"/:id",
@@ -39,5 +43,6 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
}),
}
);
export default Berita;

View File

@@ -0,0 +1,29 @@
import prisma from '@/lib/prisma';
export default async function pengumumanFindFirst() {
try {
const result = await prisma.pengumuman.findFirst({
where: {
isActive: true, // opsional kalau kamu punya field ini
},
orderBy: {
createdAt: 'desc', // ambil yang paling terbaru
},
include: {
CategoryPengumuman: true,
}
});
return {
success: true,
message: 'Berhasil ambil pengumuman terbaru',
data: result,
};
} catch (error) {
console.error('[findFirstPengumuman] Error:', error);
return {
success: false,
message: 'Gagal ambil pengumuman terbaru',
};
}
}

View File

@@ -0,0 +1,18 @@
import prisma from "@/lib/prisma";
export default async function pengumumanFindRecent() {
const result = await prisma.pengumuman.findMany({
orderBy: {
createdAt: "desc",
},
take: 4, // ambil 4 data terbaru
include: {
CategoryPengumuman: true,
},
});
return {
success: true,
data: result,
};
}

View File

@@ -6,6 +6,8 @@ import pengumumanCategoryFindMany from "./category";
import pengumumanDelete from "./del";
import pengumumanFindById from "./find-by-id";
import pengumumanUpdate from "./updt";
import pengumumanFindFirst from "./findFirst";
import pengumumanFindRecent from "./findRecent";
const Pengumuman = new Elysia({ prefix: "/pengumuman", tags: ["Desa/Pengumuman"] })
.get("/category/find-many", pengumumanCategoryFindMany)
@@ -20,6 +22,8 @@ const Pengumuman = new Elysia({ prefix: "/pengumuman", tags: ["Desa/Pengumuman"]
categoryPengumumanId: t.Union([t.String(), t.Null()]),
}),
})
.get("/find-first", pengumumanFindFirst)
.get("/find-recent", pengumumanFindRecent)
.put("/:id", pengumumanUpdate, {
body: t.Object({
id: t.String(),

View File

@@ -1,27 +1,136 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors';
import { Box, Paper, Stack, Text } from '@mantine/core';
import { IconBell } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { Box, Card, Divider, Grid, GridCol, Image, Paper, Stack, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
import BackButton from '../../../desa/layanan/_com/BackButto';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
dayjs.extend(relativeTime);
function InformasiDesa() {
const stateBerita = useProxy(stateDashboardBerita.berita)
const statePengumuman = useProxy(stateDesaPengumuman.pengumuman)
useEffect(() => {
stateBerita.findFirst.load();
stateBerita.findRecent.load();
statePengumuman.findFirst.load();
statePengumuman.findRecent.load();
}, []);
const dataBerita = stateBerita.findFirst.data
const dataPengumuman = statePengumuman.findFirst.data
return (
<Box>
<Stack >
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.8 }}
>
<Paper p={'xl'} >
<Box>
<IconBell size={50} color={colors['blue-button']} />,
</Box>
<Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>Informasi Desa</Text>
<Text fz={'lg'} c={'black'}>Akses berita dan pengumuman terbaru seputar kegiatan desa</Text>
</Paper>
</motion.div>
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }} >
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
Informasi Desa
</Text>
</Box>
<Box px={{ base: "md", md: 100 }}>
<Stack gap={10}>
{dataBerita && (
<Paper shadow="md" radius="md" p="md">
<Grid>
<GridCol span={{ md: 6, base: 12 }}>
<Image
src={dataBerita.image?.link || "/fallback.jpg"}
alt={dataBerita.judul}
radius="md"
fit="cover"
height={250}
maw={600}
/>
</GridCol>
<GridCol span={{ md: 6, base: 12 }}>
<Box>
<Text fz="sm" c="dimmed">{dataBerita.kategoriBerita?.name} {dayjs(dataBerita.createdAt).fromNow()}</Text>
<Title order={1} fw="bold">{dataBerita.judul}</Title>
<Text ta={"justify"} mt="xs" fz="md" dangerouslySetInnerHTML={{ __html: dataBerita.content }} />
</Box>
</GridCol>
</Grid>
</Paper>
)}
<Stack py={10}>
<Title order={3}>Berita Terbaru</Title>
<Grid>
{stateBerita.findRecent.data.map((item) => (
<GridCol span={{ base: 12, sm: 6, md: 3 }} key={item.id}>
<Card shadow="sm" radius="md" withBorder h="100%">
<Card.Section>
<Image
src={item.image?.link || "/placeholder.jpg"}
alt={item.judul}
height={160} // gambar fix height
fit="cover"
/>
</Card.Section>
<Stack gap="xs" mt="sm">
<Text fw={600} lineClamp={2}>
{item.judul}
</Text>
<Text size="sm" color="dimmed" lineClamp={2}>
{item.deskripsi}
</Text>
<Text size="xs" c="gray">
{dayjs(item.createdAt).fromNow()}
</Text>
</Stack>
</Card>
</GridCol>
))}
</Grid>
</Stack>
<Divider color={colors['blue-button']} my="md" />
{dataPengumuman && (
<Paper shadow="md" radius="md" p="md">
<Stack gap={"xs"}>
<Title order={1} fw="bold">{dataPengumuman.judul}</Title>
<Text fz="sm" c="dimmed">{dataPengumuman.CategoryPengumuman?.name} {dayjs(dataPengumuman.createdAt).fromNow()}</Text>
<Box>
<Text ta={"justify"} mt="xs" fz="md" dangerouslySetInnerHTML={{ __html: dataPengumuman.content }} />
</Box>
</Stack>
</Paper>
)}
<Stack py={10}>
<Title order={3}>Pengumuman Terbaru</Title>
<Grid>
{statePengumuman.findRecent.data.map((item) => (
<GridCol span={{ base: 12, sm: 6, md: 3 }} key={item.id}>
<Card shadow="sm" radius="md" withBorder h="100%">
<Stack gap="xs" mt="sm">
<Text fw={600} lineClamp={2}>
{item.judul}
</Text>
<Text size="sm" color="dimmed" lineClamp={2}>
{item.deskripsi}
</Text>
<Text size="xs" c="gray">
{dayjs(item.createdAt).fromNow()}
</Text>
</Stack>
</Card>
</GridCol>
))}
</Grid>
</Stack>
</Stack>
</Box>
</Box>
</Stack>
);
}

View File

@@ -1,13 +1,16 @@
'use client'
import colors from '@/con/colors';
import { Box, SimpleGrid, Stack, Text } from '@mantine/core';
import { Box, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { IconBell } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import BackButton from '../../desa/layanan/_com/BackButto';
import AdministrasiOnline from './administrasi-online/page';
import InformasiDesa from './informasi-desa/page';
import PengaduanMasyarakat from './pengaduan-masyarakat/page';
function Page() {
const router = useRouter()
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}>
@@ -29,7 +32,23 @@ function Page() {
<Stack gap={'lg'}>
<AdministrasiOnline />
<PengaduanMasyarakat />
<InformasiDesa />
<Box>
<Stack>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.8 }}
onClick={() => router.push('/darmasaba/inovasi/layanan-online-desa/informasi-desa')}
>
<Paper p={'xl'} >
<Box>
<IconBell size={50} color={colors['blue-button']} />
</Box>
<Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>Informasi Desa</Text>
<Text fz={'lg'} c={'black'}>Akses berita dan pengumuman terbaru seputar kegiatan desa</Text>
</Paper>
</motion.div>
</Stack>
</Box>
</Stack>
</SimpleGrid>
</Stack>

View File

@@ -82,7 +82,7 @@ function PengaduanMasyarakat() {
>
<Paper p={'xl'} >
<Box>
<IconMessageCircleQuestion size={50} color={colors['blue-button']} />,
<IconMessageCircleQuestion size={50} color={colors['blue-button']} />
</Box>
<Text fz={'h3'} fw={'bold'} c={colors['blue-button']}>Pengaduan Masyarakat</Text>
<Text fz={'lg'} c={'black'}>Sampaikan keluhan dan aspirasi Anda melalui platform digital kami</Text>

View File

@@ -34,7 +34,7 @@ export function Navbar() {
}}
size={80} radius={"xl"}
>
<Image src="/api/img/darmasaba-icon.png" alt="Logo Desa" width={50} height={50} />
<Image src="/darmasaba-icon.png" alt="Logo Desa" width={50} height={50} />
</ActionIcon>
<Burger onClick={() => stateNav.mobileOpen = !stateNav.mobileOpen} color={colors["blue-button"]} opened={mobileOpen} />
</Group>