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:
@@ -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) {
|
||||
|
||||
141
src/app/admin/(dashboard)/desa/event-budaya/[id]/page.tsx
Normal file
141
src/app/admin/(dashboard)/desa/event-budaya/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(dashboard)/desa/event-budaya/layout.tsx
Normal file
16
src/app/admin/(dashboard)/desa/event-budaya/layout.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
120
src/app/darmasaba/(pages)/desa/event-budaya/page.tsx
Normal file
120
src/app/darmasaba/(pages)/desa/event-budaya/page.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user