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:
@@ -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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.46",
|
||||
"version": "0.1.47",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal file
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal 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;
|
||||
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal file
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal file
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
128
src/app/darmasaba/(pages)/desa/kegiatan-desa/_lib/layoutTabs.tsx
Normal file
128
src/app/darmasaba/(pages)/desa/kegiatan-desa/_lib/layoutTabs.tsx
Normal 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;
|
||||
35
src/app/darmasaba/(pages)/desa/kegiatan-desa/layout.tsx
Normal file
35
src/app/darmasaba/(pages)/desa/kegiatan-desa/layout.tsx
Normal 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>;
|
||||
}
|
||||
169
src/app/darmasaba/(pages)/desa/kegiatan-desa/semua/page.tsx
Normal file
169
src/app/darmasaba/(pages)/desa/kegiatan-desa/semua/page.tsx
Normal 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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user