feat(kegiatan-desa): add full CRUD frontend + public detail page - bump to 0.1.47

- API: add GET /:id endpoint (findUnique) for KegiatanDesa
- Admin CMS: add pages for list-kegiatan-desa and kategori-kegiatan-desa (list, create, detail, edit)
- Public: add detail page at /desa/kegiatan-desa/[kategori]/[id]
- Refactor: move KegiatanCard to _com to fix Next.js page export constraint
- Nav: register kegiatan-desa in navbar and admin page list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:27:28 +08:00
parent 23c955597e
commit e0a5177257
21 changed files with 2638 additions and 4 deletions

View File

@@ -8,7 +8,7 @@
- **UI**: Mantine UI v7-8
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
- **Auth**: iron-session + JWT
- **File storage**: Local uploads + Seafile (self-hosted)
- **File storage**: Local uploads + MinIO (object storage) + Seafile (self-hosted fallback)
## Request Flow
@@ -20,14 +20,16 @@ Browser → Next.js middleware (src/middleware.ts)
└── _lib/*.ts (domain modules)
```
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
The Elysia server is a single entry point with domain-specific modules: `desa/`, `kesehatan/`, `ekonomi/`, `keamanan/`, `lingkungan/`, `pendidikan/`, `kependudukan/`, `ppid/`, `inovasi/`, `landing_page/`, `search/`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
## Domain Modules
Each domain (desa, kesehatan, ekonomi, etc.) has:
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>/`
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
Active domains: `desa`, `ekonomi`, `inovasi`, `keamanan`, `kependudukan`, `kesehatan`, `lingkungan`, `musik`, `pendidikan`, `ppid` — plus `landing_page` and `search` (API-only, no public/admin pages).
## Key Files
| File | Purpose |

View File

@@ -1,6 +1,6 @@
{
"name": "desa-darmasaba",
"version": "0.1.46",
"version": "0.1.47",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -0,0 +1,409 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// ========================================= SCHEMAS ========================================= //
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsiSingkat: z.string().min(3, "Deskripsi singkat minimal 3 karakter"),
deskripsiLengkap: z.string().min(3, "Deskripsi lengkap minimal 3 karakter"),
tanggal: z.string().nonempty("Tanggal harus diisi"),
lokasi: z.string().min(3, "Lokasi minimal 3 karakter"),
partisipan: z.number().optional().default(0),
kategoriKegiatanId: z.string().nonempty("Kategori kegiatan harus dipilih"),
imageId: z.string().optional(),
});
const defaultForm = {
judul: "",
deskripsiSingkat: "",
deskripsiLengkap: "",
tanggal: "",
lokasi: "",
partisipan: 0,
kategoriKegiatanId: "",
imageId: "" as string | undefined,
};
const templateKategori = z.object({
nama: z.string().min(1, "Nama kategori harus diisi"),
});
const defaultKategori = {
nama: "",
};
// ========================================= KEGIATAN DESA ========================================= //
const kegiatan = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(kegiatan.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
kegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kegiatandesa"]["create"].post(
kegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.load();
toast.success("Kegiatan desa berhasil disimpan!");
return true;
}
toast.error(res.data?.message || "Gagal menyimpan kegiatan desa");
return false;
} catch (error) {
console.error("Error creating kegiatan:", error);
toast.error("Terjadi kesalahan saat menyimpan kegiatan");
return false;
} finally {
kegiatan.create.loading = false;
}
},
resetForm() {
kegiatan.create.form = { ...defaultForm };
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", kategori = "") => {
const startTime = Date.now();
kegiatan.findMany.loading = true;
kegiatan.findMany.page = page;
kegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.desa["kegiatandesa"]["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.data = res.data.data ?? [];
kegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kegiatan paginated:", err);
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
} finally {
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
kegiatan.findMany.loading = false;
}, delay);
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
if (!id) return;
this.loading = true;
try {
const res = await fetch(`/api/desa/kegiatandesa/${id}`); // Assuming unique endpoint follows standard
if (res.ok) {
const result = await res.json();
kegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error("Error fetching unique kegiatan:", error);
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
kegiatan.delete.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/del/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Kegiatan berhasil dihapus");
await kegiatan.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus kegiatan");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus kegiatan");
} finally {
kegiatan.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) return null;
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${id}`);
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
judul: data.judul,
deskripsiSingkat: data.deskripsiSingkat,
deskripsiLengkap: data.deskripsiLengkap,
tanggal: data.tanggal ? new Date(data.tanggal).toISOString().split('T')[0] : "",
lokasi: data.lokasi,
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || "",
imageId: data.imageId || undefined,
};
return data;
}
} catch (error) {
console.error("Error loading kegiatan for edit:", error);
} finally {
kegiatan.edit.loading = false;
}
return null;
},
async update() {
const cek = templateForm.safeParse(kegiatan.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(kegiatan.edit.form),
});
const result = await response.json();
if (result.success) {
toast.success("Berhasil update kegiatan");
await kegiatan.findMany.load();
return true;
}
throw new Error(result.message || "Gagal update kegiatan");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Terjadi kesalahan saat update");
return false;
} finally {
kegiatan.edit.loading = false;
}
},
reset() {
kegiatan.edit.id = "";
kegiatan.edit.form = { ...defaultForm };
},
},
});
// ========================================= KATEGORI KEGIATAN ========================================= //
const kategoriKegiatan = proxy({
create: {
form: { ...defaultKategori },
loading: false,
async create() {
const cek = templateKategori.safeParse(kategoriKegiatan.create.form);
if (!cek.success) {
return toast.error("Nama kategori harus diisi");
}
try {
kategoriKegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kategorikegiatan"]["create"].post(
kategoriKegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.load();
toast.success("Kategori kegiatan berhasil dibuat");
return true;
}
toast.error(res.data?.message || "Gagal membuat kategori");
return false;
} catch (error) {
console.error(error);
toast.error("Terjadi kesalahan");
return false;
} finally {
kategoriKegiatan.create.loading = false;
}
},
resetForm() {
kategoriKegiatan.create.form = { ...defaultKategori };
},
},
findMany: {
data: [] as any[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
kategoriKegiatan.findMany.loading = true;
kategoriKegiatan.findMany.page = page;
kategoriKegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa["kategorikegiatan"]["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.data = res.data.data ?? [];
kategoriKegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriKegiatan.findMany.data = [];
}
} catch (err) {
console.error("Gagal fetch kategori kegiatan:", err);
} finally {
kategoriKegiatan.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
if (res.ok) {
const result = await res.json();
kategoriKegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error(error);
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return;
try {
kategoriKegiatan.delete.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message);
kategoriKegiatan.findMany.load();
} else {
toast.error(result.message);
}
} catch (error) {
console.error(error);
} finally {
kategoriKegiatan.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...defaultKategori },
loading: false,
async load(id: string) {
if (!id) return;
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
const result = await res.json();
if (result.success) {
this.id = result.data.id;
this.form = { nama: result.data.nama };
}
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
},
async update() {
const cek = templateKategori.safeParse(kategoriKegiatan.update.form);
if (!cek.success) return toast.error("Nama kategori harus diisi");
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const result = await res.json();
if (result.success) {
toast.success("Berhasil update kategori");
kategoriKegiatan.findMany.load();
return true;
}
toast.error(result.message);
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
return false;
},
reset() {
this.id = "";
this.form = { ...defaultKategori };
},
},
});
// ========================================= GLOBAL STATE ========================================= //
const stateDashboardKegiatan = proxy({
kegiatan,
kategoriKegiatan,
});
export default stateDashboardKegiatan;

View File

@@ -0,0 +1,106 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendarEvent, IconCategory } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "List Kegiatan",
value: "list_kegiatan",
href: "/admin/desa/kegiatan-desa/list-kegiatan-desa",
icon: <IconCalendarEvent size={18} stroke={1.8} />
},
{
label: "Kategori Kegiatan",
value: "kategori_kegiatan",
href: "/admin/desa/kegiatan-desa/kategori-kegiatan-desa",
icon: <IconCategory size={18} stroke={1.8} />
},
];
const currentTab = tabs.find(tab => tab.href === pathname);
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value);
if (tab) {
router.push(tab.href);
}
setActiveTab(value);
};
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname);
if (match) {
setActiveTab(match.value);
}
}, [pathname]);
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Kegiatan Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsKegiatanDesa;

View File

@@ -0,0 +1,137 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditKategoriKegiatan() {
const editState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ nama: '' });
const [originalData, setOriginalData] = useState({ nama: '' });
const isFormValid = () => formData.nama?.trim() !== '';
useEffect(() => {
const loadKategori = async () => {
const id = params?.id as string;
if (!id) return;
try {
await stateDashboardKegiatan.kategoriKegiatan.update.load(id);
const nama = stateDashboardKegiatan.kategoriKegiatan.update.form.nama || '';
setFormData({ nama });
setOriginalData({ nama });
} catch (error) {
console.error('Error loading kategori kegiatan:', error);
toast.error('Gagal memuat data kategori kegiatan');
}
};
loadKategori();
}, [params?.id]);
const handleResetForm = () => {
setFormData({ nama: originalData.nama });
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
if (!formData.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
try {
setIsSubmitting(true);
editState.update.form = {
...editState.update.form,
nama: formData.nama,
};
const success = await editState.update.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kategori kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={formData.nama}
onChange={(e) => setFormData((prev) => ({ ...prev, nama: e.target.value }))}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditKategoriKegiatan;

View File

@@ -0,0 +1,107 @@
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriKegiatan() {
const createState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormValid = () => createState.create.form.nama?.trim() !== '';
const resetForm = () => {
createState.create.resetForm();
};
const handleSubmit = async () => {
if (!createState.create.form.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
setIsSubmitting(true);
try {
const success = await createState.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kategori kegiatan:', error);
toast.error('Gagal menambahkan kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateKategoriKegiatan;

View File

@@ -0,0 +1,239 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KategoriKegiatanDesa() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Kategori Kegiatan"
placeholder="Cari nama kategori kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKategoriKegiatan search={search} />
</Box>
);
}
function ListKategoriKegiatan({ search }: { search: string }) {
const listDataState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, loading, load, page, totalPages } = listDataState.findMany;
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleDelete = () => {
if (selectedId) {
listDataState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={4} lh={1.2}>
Daftar Kategori Kegiatan
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa/create')
}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="60%">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={500} lh={1.45} truncate="end">
{item.nama}
</Text>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
size="compact-sm"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-sm"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box flex={1} ml="md">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.45} truncate>
{item.nama}
</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center mt={{ base: 'lg', md: 'xl' }}>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus kategori kegiatan ini?"
/>
</Box>
);
}
export default KategoriKegiatanDesa;

View File

@@ -0,0 +1,28 @@
'use client'
import React from 'react';
import LayoutTabsKegiatanDesa from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabsKegiatanDesa>
{children}
</LayoutTabsKegiatanDesa>
);
}
export default Layout;

View File

@@ -0,0 +1,335 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
interface KegiatanData {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatanId: string | null;
imageId: string | null;
image?: { link: string } | null;
}
function EditKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const emptyForm = {
judul: '',
deskripsiSingkat: '',
deskripsiLengkap: '',
tanggal: '',
lokasi: '',
partisipan: 0,
kategoriKegiatanId: '',
imageId: '',
};
const [formData, setFormData] = useState(emptyForm);
const [originalData, setOriginalData] = useState({ ...emptyForm, imageUrl: '' });
const isFormValid = () =>
formData.judul.trim() !== '' &&
formData.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(formData.deskripsiLengkap) &&
formData.tanggal !== '' &&
formData.lokasi.trim() !== '' &&
formData.kategoriKegiatanId !== '';
useEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await stateDashboardKegiatan.kegiatan.edit.load(id) as KegiatanData | null;
if (data) {
const tanggal = data.tanggal
? new Date(data.tanggal).toISOString().split('T')[0]
: '';
const form = {
judul: data.judul || '',
deskripsiSingkat: data.deskripsiSingkat || '',
deskripsiLengkap: data.deskripsiLengkap || '',
tanggal,
lokasi: data.lokasi || '',
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || '',
imageId: data.imageId || '',
};
setFormData(form);
setOriginalData({ ...form, imageUrl: data.image?.link || '' });
if (data.image?.link) setPreviewImage(data.image.link);
}
} catch (error) {
console.error('Error loading kegiatan:', error);
toast.error('Gagal memuat data kegiatan');
}
};
load();
}, [params?.id]);
const handleChange = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (!formData.judul.trim()) return toast.error('Judul wajib diisi');
if (!formData.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(formData.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!formData.tanggal) return toast.error('Tanggal wajib diisi');
if (!formData.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!formData.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
kegiatanState.kegiatan.edit.form = {
...kegiatanState.kegiatan.edit.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
kegiatanState.kegiatan.edit.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.edit.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kegiatan');
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
setFormData({
judul: originalData.judul,
deskripsiSingkat: originalData.deskripsiSingkat,
deskripsiLengkap: originalData.deskripsiLengkap,
tanggal: originalData.tanggal,
lokasi: originalData.lokasi,
partisipan: originalData.partisipan,
kategoriKegiatanId: originalData.kategoriKegiatanId,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info('Form dikembalikan ke data awal');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Edit Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={formData.judul}
onChange={(e) => handleChange('judul', e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={formData.kategoriKegiatanId || null}
onChange={(val) => handleChange('kategoriKegiatanId', val || '')}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={formData.tanggal}
onChange={(e) => handleChange('tanggal', e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={formData.lokasi}
onChange={(e) => handleChange('lokasi', e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={formData.partisipan}
onChange={(val) => handleChange('partisipan', Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={formData.deskripsiSingkat}
onChange={(e) => handleChange('deskripsiSingkat', e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<EditEditor
value={formData.deskripsiLengkap}
onChange={(html) => setFormData((prev) => ({ ...prev, deskripsiLengkap: html }))}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={160}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => { setPreviewImage(null); setFile(null); }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleReset}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditKegiatanDesa;

View File

@@ -0,0 +1,189 @@
'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
interface KegiatanDetail {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatan?: { nama: string } | null;
image?: { link: string } | null;
}
function DetailKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
kegiatanState.kegiatan.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
kegiatanState.kegiatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
};
if (!kegiatanState.kegiatan.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = kegiatanState.kegiatan.findUnique.data as unknown as KegiatanDetail;
const formatTanggal = (val: string) => {
if (!val) return '-';
return new Date(val).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} 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">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Kegiatan Desa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriKegiatan?.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Tanggal</Text>
<Text fz="md" c="dimmed">{formatTanggal(data.tanggal)}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Lokasi</Text>
<Text fz="md" c="dimmed">{data.lokasi || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Partisipan</Text>
<Text fz="md" c="dimmed">{data.partisipan ?? 0} orang</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Singkat</Text>
<Text fz="md" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{data.deskripsiSingkat || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Lengkap</Text>
<Paper bg="white" p="md" radius="md" mt="xs">
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '-' }}
/>
</Paper>
</Box>
{data.image?.link && (
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Image
src={data.image.link}
alt={data.judul || 'Gambar Kegiatan'}
w={{ base: '100%', md: 400 }}
h={300}
radius="md"
fit="cover"
loading="lazy"
mt="xs"
/>
</Box>
)}
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => { setSelectedId(data.id); setModalHapus(true); }}
variant="light"
radius="md"
size="md"
leftSection={<IconTrash size={20} />}
>
Hapus
</Button>
<Button
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/list-kegiatan-desa/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
leftSection={<IconEdit size={20} />}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus kegiatan desa ini?"
/>
</Box>
);
}
export default DetailKegiatanDesa;

View File

@@ -0,0 +1,258 @@
'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
export default function CreateKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
}, []);
const isFormValid = () => {
const f = kegiatanState.kegiatan.create.form;
return (
f.judul.trim() !== '' &&
f.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(f.deskripsiLengkap) &&
f.tanggal !== '' &&
f.lokasi.trim() !== '' &&
f.kategoriKegiatanId !== ''
);
};
const resetForm = () => {
kegiatanState.kegiatan.create.resetForm();
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
const f = kegiatanState.kegiatan.create.form;
if (!f.judul.trim()) return toast.error('Judul wajib diisi');
if (!f.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(f.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!f.tanggal) return toast.error('Tanggal wajib diisi');
if (!f.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!f.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal mengunggah gambar');
kegiatanState.kegiatan.create.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kegiatan:', error);
toast.error('Terjadi kesalahan saat membuat kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Tambah Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={kegiatanState.kegiatan.create.form.judul}
onChange={(e) => (kegiatanState.kegiatan.create.form.judul = e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={kegiatanState.kegiatan.create.form.kategoriKegiatanId || null}
onChange={(val) => {
kegiatanState.kegiatan.create.form.kategoriKegiatanId = val || '';
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={kegiatanState.kegiatan.create.form.tanggal}
onChange={(e) => (kegiatanState.kegiatan.create.form.tanggal = e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={kegiatanState.kegiatan.create.form.lokasi}
onChange={(e) => (kegiatanState.kegiatan.create.form.lokasi = e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={kegiatanState.kegiatan.create.form.partisipan}
onChange={(val) => (kegiatanState.kegiatan.create.form.partisipan = Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={kegiatanState.kegiatan.create.form.deskripsiSingkat}
onChange={(e) => (kegiatanState.kegiatan.create.form.deskripsiSingkat = e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<CreateEditor
value={kegiatanState.kegiatan.create.form.deskripsiLengkap}
onChange={(html) => { kegiatanState.kegiatan.create.form.deskripsiLengkap = html; }}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={160}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => { setPreviewImage(null); setFile(null); }}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,203 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KegiatanDesa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title="Kegiatan Desa"
placeholder="Cari judul atau lokasi kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKegiatanDesa search={search} />
</Box>
);
}
function ListKegiatanDesa({ search }: { search: string }) {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = kegiatanState.kegiatan.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
if (loading || !data) {
return (
<Stack py="md">
<Skeleton height={600} radius="md" />
</Stack>
);
}
const filteredData = data || [];
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Kegiatan Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="35%">Judul</TableTh>
<TableTh w="25%">Kategori</TableTh>
<TableTh w="20%">Lokasi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={600} lh={1.45} truncate="end">
{item.judul}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.kategoriKegiatan?.nama || '-'}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45} truncate="end">
{item.lokasi || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
px="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">Judul</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.judul}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Kategori</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.kategoriKegiatan?.nama || '-'}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Lokasi</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.lokasi || '-'}</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default KegiatanDesa;

View File

@@ -118,6 +118,11 @@ export const devBar = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]
@@ -549,6 +554,11 @@ export const navBar = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]
@@ -995,6 +1005,11 @@ export const role1 = [
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
},
{
id: "Desa_8",
name: "Kegiatan Desa",
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
}
]

View File

@@ -0,0 +1,35 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kegiatanDesaFindUnique(context: Context) {
const { id } = context.params as { id: string };
if (!id) {
return { success: false, message: "ID is required" };
}
try {
const data = await prisma.kegiatanDesa.findUnique({
where: { id },
include: {
kategoriKegiatan: true,
image: true,
},
});
if (!data) {
return { success: false, message: "Data tidak ditemukan" };
}
return {
success: true,
message: "Berhasil ambil kegiatan desa",
data,
};
} catch (e) {
console.error("Error di kegiatanDesaFindUnique:", e);
return { success: false, message: "Gagal mengambil data kegiatan desa" };
}
}
export default kegiatanDesaFindUnique;

View File

@@ -1,11 +1,13 @@
import Elysia, { t } from "elysia";
import kegiatanDesaFindMany from "./find-many";
import kegiatanDesaFindUnique from "./findUnique";
import kegiatanDesaCreate from "./create";
import kegiatanDesaDelete from "./del";
import kegiatanDesaUpdate from "./updt";
const KegiatanDesa = new Elysia({ prefix: "/kegiatandesa", tags: ["Desa/Kegiatan Desa"] })
.get("/find-many", kegiatanDesaFindMany)
.get("/:id", kegiatanDesaFindUnique)
.post("/create", kegiatanDesaCreate, {
body: t.Object({
judul: t.String(),

View File

@@ -0,0 +1,132 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Badge,
Box,
Center,
Container,
Divider,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
import { useParams } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
const formatTanggal = (val: string) =>
val
? new Date(val).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
: '-';
function Page() {
const params = useParams<{ id: string }>();
const id = Array.isArray(params.id) ? params.id[0] : params.id;
const state = useProxy(stateDashboardKegiatan.kegiatan.findUnique);
useEffect(() => {
if (id) state.load(id);
}, [id]);
if (state.loading) {
return (
<Box bg={colors.Bg} py={40}>
<Container size="lg">
<Stack gap="md">
<Skeleton h={400} radius="lg" />
<Skeleton h={30} w="60%" />
<Skeleton h={20} w="40%" />
<Skeleton h={200} />
</Stack>
</Container>
</Box>
);
}
const data = state.data;
if (!data) {
return (
<Center h="60vh" bg={colors.Bg}>
<Text fz="xl" c="dimmed">Data kegiatan tidak ditemukan</Text>
</Center>
);
}
return (
<Box bg={colors.Bg} py={40}>
<Container size="lg" px={{ base: 'md', md: 'xl' }}>
<Paper shadow="sm" radius="lg" withBorder p={{ base: 'md', md: 'xl' }}>
<Stack gap="lg">
{data.image?.link && (
<Box
w="100%"
h={{ base: 240, md: 420 }}
style={{ overflow: 'hidden', borderRadius: 'var(--mantine-radius-lg)' }}
>
<Image
src={data.image.link}
alt={data.judul}
fit="cover"
w="100%"
h="100%"
fallbackSrc="https://placehold.co/800x400?text=Gambar+tidak+tersedia"
loading="lazy"
/>
</Box>
)}
<Stack gap="xs">
<Badge color="blue" variant="light" size="md" radius="md" w="fit-content">
{data.kategoriKegiatan?.nama || 'Kegiatan Desa'}
</Badge>
<Title order={2} lh={1.3}>
{data.judul}
</Title>
<Group gap="xl" wrap="wrap">
<Group gap={6}>
<IconCalendar size={16} color="gray" />
<Text fz="sm" c="dimmed">{formatTanggal(data.tanggal)}</Text>
</Group>
<Group gap={6}>
<IconMapPin size={16} color="gray" />
<Text fz="sm" c="dimmed">{data.lokasi || '-'}</Text>
</Group>
<Group gap={6}>
<IconUsers size={16} color="gray" />
<Text fz="sm" c="dimmed">{data.partisipan ?? 0} partisipan</Text>
</Group>
</Group>
</Stack>
<Divider />
{data.deskripsiSingkat && (
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed" fw={500} fs="italic">
{data.deskripsiSingkat}
</Text>
)}
<Text
fz={{ base: 'sm', md: 'md' }}
lh={{ base: 1.7, md: 1.9 }}
ta="justify"
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '' }}
/>
</Stack>
</Paper>
</Container>
</Box>
);
}
export default Page;

View File

@@ -0,0 +1,100 @@
'use client'
import colors from '@/con/colors';
import {
Badge,
Button,
Card,
Divider,
Flex,
Group,
Image,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconArrowRight, IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
const formatTanggal = (val: string) =>
val
? new Date(val).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
: '-';
export function KegiatanCard({
item,
onNavigate,
}: {
item: {
id: string;
judul: string;
deskripsiSingkat: string;
tanggal: string;
lokasi?: string;
partisipan?: number;
image?: { link: string } | null;
kategoriKegiatan?: { nama: string } | null;
};
onNavigate: () => void;
}) {
return (
<Card
shadow="sm"
radius="lg"
withBorder
style={{ cursor: 'pointer', transition: 'box-shadow 0.2s' }}
onClick={onNavigate}
>
<Card.Section>
<Image
src={item.image?.link || '/images/placeholder-small.jpg'}
height={200}
alt={item.judul}
fit="cover"
loading="lazy"
/>
</Card.Section>
<Stack mt="md" gap="xs">
<Badge color="blue" variant="light" size="sm" radius="md" w="fit-content">
{item.kategoriKegiatan?.nama || 'Kegiatan'}
</Badge>
<Title order={4} lineClamp={2} lh={1.35} fz={{ base: 'sm', md: 'md' }}>
{item.judul}
</Title>
<Text c="dimmed" lineClamp={2} fz={{ base: 'xs', md: 'sm' }} lh={1.55}>
{item.deskripsiSingkat}
</Text>
<Divider my={4} />
<Stack gap={4}>
<Group gap={6} wrap="nowrap">
<IconCalendar size={13} color="gray" />
<Text fz="xs" c="dimmed">{formatTanggal(item.tanggal)}</Text>
</Group>
<Group gap={6} wrap="nowrap">
<IconMapPin size={13} color="gray" />
<Text fz="xs" c="dimmed" lineClamp={1}>{item.lokasi || '-'}</Text>
</Group>
<Group gap={6} wrap="nowrap">
<IconUsers size={13} color="gray" />
<Text fz="xs" c="dimmed">{item.partisipan ?? 0} partisipan</Text>
</Group>
</Stack>
<Flex justify="flex-end" mt="xs">
<Button
size="compact-sm"
variant="light"
color={colors['blue-button']}
rightSection={<IconArrowRight size={14} />}
radius="md"
>
Lihat Detail
</Button>
</Flex>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { Box, Group, Stack, Tabs, TabsList, TabsTab, Text, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const kategoriState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const activeTab = pathname.split('/').pop() || 'semua';
const [activeTabState, setActiveTabState] = useState(activeTab);
useEffect(() => {
kategoriState.findMany.load(1, 100);
}, []);
useEffect(() => {
setActiveTabState(activeTab);
}, [activeTab]);
const initialSearch = searchParams.get('search') || '';
const [searchValue, setSearchValue] = useState(initialSearch);
const [searchTimeout, setSearchTimeout] = useState<number | null>(null);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchValue(value);
if (searchTimeout !== null) clearTimeout(searchTimeout);
const newTimeout = window.setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set('search', value);
else params.delete('search');
router.push(
`/darmasaba/desa/kegiatan-desa/${activeTab}${params.toString() ? `?${params.toString()}` : ''}`
);
}, 500);
setSearchTimeout(newTimeout);
};
const tabs = [
{ label: 'Semua', value: 'semua', href: '/darmasaba/desa/kegiatan-desa/semua' },
...(kategoriState.findMany.data || []).map((kat: any) => ({
label: kat.nama,
value: kat.nama.toLowerCase(),
href: `/darmasaba/desa/kegiatan-desa/${kat.nama.toLowerCase()}`,
})),
];
const handleTabChange = (value: string | null) => {
if (!value) return;
const tab = tabs.find((t) => t.value === value);
if (tab) {
const params = new URLSearchParams(searchParams.toString());
router.push(`${tab.href}${params.toString() ? `?${params.toString()}` : ''}`);
}
};
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Stack gap={4}>
<Text fz={{ base: '2rem', md: '3.4rem' }} c={colors['blue-button']} fw="bold" lh={1.2}>
Kegiatan Desa Darmasaba
</Text>
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed">
Temukan berbagai kegiatan dan program yang dilaksanakan Desa Darmasaba
</Text>
</Stack>
<Box w={{ base: '100%', sm: 260 }}>
<TextInput
radius="lg"
placeholder="Cari kegiatan..."
leftSection={<IconSearch size={18} />}
value={searchValue}
onChange={handleSearchChange}
/>
</Box>
</Group>
</Box>
<Tabs
color={colors['blue-button']}
variant="pills"
value={activeTabState}
onChange={handleTabChange}
>
<Box px={{ base: 'md', md: 100 }} py="md" bg={colors['BG-trans']}>
<Box style={{ overflowX: 'auto', whiteSpace: 'nowrap' }}>
<TabsList style={{ display: 'flex', flexWrap: 'nowrap', gap: '0.5rem' }}>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
onClick={() => router.push(tab.href)}
style={{ flex: '0 0 auto', minWidth: 100, textAlign: 'center' }}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</Box>
</Box>
{children}
</Tabs>
</Stack>
);
}
export default LayoutTabsKegiatanDesa;

View File

@@ -0,0 +1,35 @@
'use client'
import colors from '@/con/colors';
import { Box } from '@mantine/core';
import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import BackButton from '../layanan/_com/BackButto';
const LayoutTabsKegiatanDesa = dynamic(
() => import('./_lib/layoutTabs'),
{ ssr: false }
);
export default function KegiatanDesaLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
// /darmasaba/desa/kegiatan-desa/semua → 4 segments → list
// /darmasaba/desa/kegiatan-desa/[kategori] → 4 segments → list
// /darmasaba/desa/kegiatan-desa/[kategori]/[id]→ 5 segments → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5;
if (isDetailPage) {
return (
<Box bg={colors.Bg}>
<Box pt={33} px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
{children}
</Box>
);
}
return <LayoutTabsKegiatanDesa>{children}</LayoutTabsKegiatanDesa>;
}

View File

@@ -0,0 +1,169 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Container,
Divider,
Grid,
GridCol,
Group,
Image,
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { KegiatanCard } from '../_com/KegiatanCard';
import { IconArrowRight, IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
const formatTanggal = (val: string) =>
val
? new Date(val).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
: '-';
function Semua() {
const router = useTransitionRouter();
const searchParams = useSearchParams();
const search = searchParams.get('search') || '';
const page = parseInt(searchParams.get('page') || '1');
const state = useProxy(stateDashboardKegiatan.kegiatan);
const loading = state.findMany.loading;
const items = state.findMany.data || [];
const totalPages = state.findMany.totalPages || 1;
useEffect(() => {
state.findMany.load(page, 7, search);
}, [page, search]);
const handlePageChange = (newPage: number) => {
const url = new URLSearchParams(searchParams.toString());
if (search) url.set('search', search);
if (newPage > 1) url.set('page', newPage.toString());
else url.delete('page');
router.replace(`?${url.toString()}`);
};
const featured = items[0] ?? null;
const rest = items.slice(1);
const toDetail = (item: { id: string; kategoriKegiatan?: { nama: string } | null }) =>
`/darmasaba/desa/kegiatan-desa/${item.kategoriKegiatan?.nama?.toLowerCase() || 'semua'}/${item.id}`;
return (
<Box py={20}>
<Container size="xl" px={{ base: 'md', md: 'xl' }}>
{/* ─── Hero / Kegiatan Utama ─── */}
{loading ? (
<Center><Skeleton h={380} radius="md" /></Center>
) : featured ? (
<Box mb={50}>
<Title order={2} mb="md" c="dark">Kegiatan Terbaru</Title>
<Paper shadow="md" radius="lg" withBorder style={{ overflow: 'hidden' }}>
<Grid gutter={0}>
<GridCol span={{ base: 12, md: 6 }}>
<Image
src={featured.image?.link || '/images/placeholderx.jpg'}
alt={featured.judul || 'Kegiatan Utama'}
height={380}
fit="cover"
style={{ borderBottomRightRadius: 0, borderTopRightRadius: 0 }}
loading="lazy"
/>
</GridCol>
<GridCol span={{ base: 12, md: 6 }} p="xl">
<Stack h="100%" justify="space-between" gap="md">
<Stack gap="sm">
<Badge color={colors['blue-button']} variant="light" size="md" radius="md">
{featured.kategoriKegiatan?.nama || 'Kegiatan'}
</Badge>
<Title order={3} lh={1.3} lineClamp={2}>
{featured.judul}
</Title>
<Text c="dimmed" lineClamp={3} fz={{ base: 'sm', md: 'md' }} lh={1.6}>
{featured.deskripsiSingkat}
</Text>
</Stack>
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<IconCalendar size={16} color={colors['blue-button']} />
<Text fz="sm" c="dimmed">{formatTanggal(featured.tanggal)}</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<IconMapPin size={16} color={colors['blue-button']} />
<Text fz="sm" c="dimmed" lineClamp={1}>{featured.lokasi || '-'}</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<IconUsers size={16} color={colors['blue-button']} />
<Text fz="sm" c="dimmed">{featured.partisipan ?? 0} partisipan</Text>
</Group>
</Stack>
<Button
variant="light"
color={colors['blue-button']}
rightSection={<IconArrowRight size={16} />}
radius="md"
onClick={() => router.push(toDetail(featured))}
>
Lihat Detail
</Button>
</Stack>
</GridCol>
</Grid>
</Paper>
</Box>
) : null}
{/* ─── Grid Kegiatan ─── */}
<Box mt={loading ? 0 : 50}>
<Title order={2} mb="md" c="dark">Semua Kegiatan</Title>
<Divider mb="xl" />
{loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl">
{Array(6).fill(0).map((_, i) => <Skeleton key={i} h={320} radius="md" />)}
</SimpleGrid>
) : rest.length === 0 && !featured ? (
<Text c="dimmed" ta="center" fz="sm" py="xl">Belum ada kegiatan desa.</Text>
) : rest.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xl" verticalSpacing="xl">
{rest.map((item) => (
<KegiatanCard key={item.id} item={item} onNavigate={() => router.push(toDetail(item))} />
))}
</SimpleGrid>
) : null}
<Center mt="xl">
<Pagination
total={totalPages}
value={page}
onChange={handlePageChange}
siblings={1}
boundaries={1}
withEdges
color={colors['blue-button']}
/>
</Center>
</Box>
</Container>
</Box>
);
}
export default Semua;

View File

@@ -85,6 +85,11 @@ const navbarListMenu = [
id: "2.7",
name: "Penghargaan",
href: "/darmasaba/penghargaan"
},
{
id: "2.8",
name: "Kegiatan Desa",
href: "/darmasaba/desa/kegiatan-desa/semua"
}
]