feat(desa): Kalender Event Budaya — fitur admin/public, seeder, pagination, fix duplicate options

- Tambah fitur Kalender Event Budaya di admin CMS (list, detail, edit, hapus)
- Tambah state Valtio (create, findMany, findUnique, edit, delete, findUpcoming)
- Tambah endpoint API /find-upcoming untuk event mendatang
- Tambah halaman public /darmasaba/desa/event-budaya dengan pagination 5 data/halaman
- Switch public page dari findUpcoming ke findMany agar pagination berjalan
- Tambah menu "Kalender Event Budaya" di navbar (id: 2.9)
- Perluas seeder event budaya: 8 → 34 events mencakup 2025-2026
- Fix: deduplikasi kategoriOptions di kegiatan-desa public page (Mantine Select error)
- Hapus STRUKTUR.md yang sudah tidak relevan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:28:13 +08:00
parent 0a3dc1dc43
commit e9e7c17ee3
12 changed files with 648 additions and 906 deletions

View File

@@ -182,6 +182,28 @@ const eventBudayaState = proxy({
},
},
findUpcoming: {
data: null as Prisma.EventBudayaGetPayload<object>[] | null,
loading: false,
async load() {
eventBudayaState.findUpcoming.loading = true;
try {
const res = await fetch("/api/desa/eventbudaya/find-upcoming");
const result = await res.json();
if (result?.success) {
eventBudayaState.findUpcoming.data = result.data ?? [];
} else {
eventBudayaState.findUpcoming.data = [];
}
} catch (error) {
console.error("Error loading upcoming events:", error);
eventBudayaState.findUpcoming.data = [];
} finally {
eventBudayaState.findUpcoming.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {

View File

@@ -0,0 +1,141 @@
'use client';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconCalendarEvent, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
export default function DetailEventBudaya() {
const state = useProxy(eventBudayaState);
const [modalHapus, setModalHapus] = useState(false);
const params = useParams();
const router = useRouter();
const id = params.id as string;
useShallowEffect(() => {
state.findUnique.load(id);
}, [id]);
const handleHapus = async () => {
await state.delete.byId(id);
setModalHapus(false);
router.push('/admin/desa/event-budaya');
};
if (state.findUnique.loading || !state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.push('/admin/desa/event-budaya')}
leftSection={<IconArrowBack size={20} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Group gap="sm">
<IconCalendarEvent size={22} color={colors['blue-button']} />
<Text fz="xl" fw="bold" c={colors['blue-button']}>
Detail Event Budaya
</Text>
</Group>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="sm" fw={600} c="dimmed">Nama Event</Text>
<Text fz="md" fw={500}>{data.nama || '-'}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} c="dimmed">Tanggal</Text>
<Text fz="md">
{new Date(data.tanggal).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} c="dimmed">Lokasi</Text>
<Text fz="md">{data.lokasi || '-'}</Text>
</Box>
{data.deskripsi && (
<Box>
<Text fz="sm" fw={600} c="dimmed">Deskripsi</Text>
<Text fz="md" style={{ wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
{data.deskripsi}
</Text>
</Box>
)}
<Group gap="sm" mt="xs">
<Button
color="red"
variant="light"
radius="md"
leftSection={<IconTrash size={16} />}
loading={state.delete.loading}
onClick={() => setModalHapus(true)}
>
Hapus
</Button>
<Button
color="blue"
variant="light"
radius="md"
leftSection={<IconEdit size={16} />}
onClick={() => router.push(`/admin/desa/event-budaya/${id}/edit`)}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus event budaya ini?"
/>
</Box>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { Stack, Title } from '@mantine/core';
import React from 'react';
function Layout({ children }: { children: React.ReactNode }) {
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: '#1A1B1E' }}>
Kalender Event Budaya
</Title>
{children}
</Stack>
);
}
export default Layout;

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
@@ -22,9 +23,9 @@ import {
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconCalendarEvent, IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { IconCalendarEvent, IconEdit, IconEye, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
@@ -37,7 +38,7 @@ function EventBudayaPage() {
placeholder="Cari nama atau lokasi..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListEventBudaya search={search} />
</Box>
@@ -48,6 +49,8 @@ function ListEventBudaya({ search }: { search: string }) {
const state = useProxy(eventBudayaState);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 500);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, page, totalPages, loading, load } = state.findMany;
@@ -55,6 +58,14 @@ function ListEventBudaya({ search }: { search: string }) {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleHapus = async () => {
if (selectedId) {
await state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
if (loading || !data) {
return (
<Stack py="md">
@@ -82,19 +93,23 @@ function ListEventBudaya({ search }: { search: string }) {
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="30%">Nama Event</TableTh>
<TableTh w="35%">Nama Event</TableTh>
<TableTh w="20%">Tanggal</TableTh>
<TableTh w="25%">Lokasi</TableTh>
<TableTh w="25%">Aksi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length > 0 ? (
data.map((item) => (
<TableTr key={item.id}>
<TableTr
key={item.id}
style={{ cursor: 'pointer' }}
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}`)}
>
<TableTd>
<Group gap="xs">
<IconCalendarEvent size={16} color="blue" />
<IconCalendarEvent size={16} color={colors['blue-button-5']} />
<Text fz="sm" fw={500} truncate="end" lineClamp={1}>
{item.nama}
</Text>
@@ -114,22 +129,29 @@ function ListEventBudaya({ search }: { search: string }) {
{item.lokasi}
</Text>
</TableTd>
<TableTd>
<TableTd onClick={(e) => e.stopPropagation()}>
<Group gap="xs">
<ActionIcon
variant="light"
color="teal"
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}`)}
>
<IconEye size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/event-budaya/${item.id}/edit`)
}
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}/edit`)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
loading={state.delete.loading}
onClick={() => state.delete.byId(item.id)}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</ActionIcon>
@@ -160,6 +182,16 @@ function ListEventBudaya({ search }: { search: string }) {
</Group>
)}
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => {
setModalHapus(false);
setSelectedId(null);
}}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus event budaya ini?"
/>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
import prisma from "@/lib/prisma";
async function eventBudayaFindUpcoming() {
const today = new Date();
today.setHours(0, 0, 0, 0);
try {
const data = await prisma.eventBudaya.findMany({
where: {
isActive: true,
tanggal: { gte: today },
},
orderBy: { tanggal: "asc" },
take: 20,
});
return { success: true, data };
} catch (e) {
console.error("Error di eventBudayaFindUpcoming:", e);
return { success: false, message: "Gagal mengambil event mendatang" };
}
}
export default eventBudayaFindUpcoming;

View File

@@ -1,5 +1,6 @@
import Elysia, { t } from "elysia";
import eventBudayaFindMany from "./find-many";
import eventBudayaFindUpcoming from "./find-upcoming";
import eventBudayaFindUnique from "./findUnique";
import eventBudayaCreate from "./create";
import eventBudayaDelete from "./del";
@@ -7,6 +8,7 @@ import eventBudayaUpdate from "./updt";
const EventBudaya = new Elysia({ prefix: "/eventbudaya", tags: ["Desa/Event Budaya"] })
.get("/find-many", eventBudayaFindMany)
.get("/find-upcoming", eventBudayaFindUpcoming)
.get("/:id", eventBudayaFindUnique)
.post("/create", eventBudayaCreate, {
body: t.Object({

View File

@@ -0,0 +1,120 @@
'use client';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
Box,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconCalendarEvent, IconMapPin } from '@tabler/icons-react';
import { useProxy } from 'valtio/utils';
const LIMIT = 5;
function KalenderEventBudayaPage() {
const state = useProxy(eventBudayaState);
const { data, loading, page, totalPages } = state.findMany;
useShallowEffect(() => {
state.findMany.load(1, LIMIT);
}, []);
const handlePageChange = (p: number) => {
state.findMany.load(p, LIMIT);
};
return (
<Box px={{ base: 'md', md: 100 }} pb="xl">
<Title order={2} mb="xl" c={colors['blue-button']}>
Kalender Event Budaya
</Title>
{loading || !data ? (
<Stack gap="md">
{Array(LIMIT)
.fill(0)
.map((_, i) => (
<Skeleton key={i} h={80} radius="lg" />
))}
</Stack>
) : data.length === 0 ? (
<Center py="xl">
<Stack align="center" gap="xs">
<IconCalendarEvent size={48} color={colors['blue-button-3']} />
<Text c="dimmed" fz="sm">
Belum ada event budaya.
</Text>
</Stack>
</Center>
) : (
<Stack gap="md">
{data.map((item) => (
<Paper
key={item.id}
withBorder
p="lg"
radius="lg"
shadow="sm"
style={{ borderLeft: `4px solid ${colors['blue-button-5']}` }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconCalendarEvent
size={22}
color={colors['blue-button-5']}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Stack gap={4} style={{ minWidth: 0 }}>
<Text fw={600} fz="md" lineClamp={2}>
{item.nama}
</Text>
<Text fz="sm" c="dimmed">
{new Date(item.tanggal).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
{item.deskripsi && (
<Text fz="sm" c="dimmed" lineClamp={2} mt={2}>
{item.deskripsi}
</Text>
)}
</Stack>
</Group>
<Group gap={4} style={{ flexShrink: 0 }} align="center">
<IconMapPin size={14} color={colors['grey']['2']} />
<Text fz="sm" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{item.lokasi}
</Text>
</Group>
</Group>
</Paper>
))}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
total={totalPages}
value={page}
onChange={handlePageChange}
color={colors['blue-button']}
radius="md"
/>
</Group>
)}
</Stack>
)}
</Box>
);
}
export default KalenderEventBudayaPage;

View File

@@ -39,10 +39,11 @@ function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
router.push(`/darmasaba/desa/kegiatan-desa/semua?${params.toString()}`);
};
const kategoriOptions = (kategoriState.findMany.data || []).map((k: any) => ({
value: k.nama,
label: k.nama,
}));
const kategoriOptions = Array.from(
new Map(
(kategoriState.findMany.data || []).map((k: any) => [k.nama, { value: k.nama, label: k.nama }])
).values()
);
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">

View File

@@ -90,6 +90,11 @@ const navbarListMenu = [
id: "2.8",
name: "Kegiatan Desa",
href: "/darmasaba/desa/kegiatan-desa/semua"
},
{
id: "2.9",
name: "Kalender Event Budaya",
href: "/darmasaba/desa/event-budaya"
}
]