Fix UI Admin menu desa

This commit is contained in:
2025-09-03 15:30:02 +08:00
parent fa9601e126
commit 2adf60f9eb
77 changed files with 6566 additions and 4402 deletions

View File

@@ -368,11 +368,37 @@ const kategoriBerita = proxy({
isActive: true; isActive: true;
}; };
}>[], }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
const res = await ApiFetch.api.desa.kategoriberita["findMany"].get(); load: async (page = 1, limit = 10, search = "") => {
if (res.status === 200) { kategoriBerita.findMany.loading = true; // ✅ Akses langsung via nama path
kategoriBerita.findMany.data = res.data?.data ?? []; kategoriBerita.findMany.page = page;
kategoriBerita.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.kategoriberita[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriBerita.findMany.data = res.data.data ?? [];
kategoriBerita.findMany.totalPages =
res.data.totalPages ?? 1;
} else {
kategoriBerita.findMany.data = [];
kategoriBerita.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kategori berita paginated:", err);
kategoriBerita.findMany.data = [];
kategoriBerita.findMany.totalPages = 1;
} finally {
kategoriBerita.findMany.loading = false;
} }
}, },
}, },

View File

@@ -30,7 +30,6 @@ const templateTelunjukSaktiDesaForm = z.object({
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"), deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
}); });
const templatePelayananPerizinanBerusaha = z.object({ const templatePelayananPerizinanBerusaha = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"), deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
@@ -72,7 +71,6 @@ const pelayananPendudukNonPermanenForm = {
deskripsi: "", deskripsi: "",
}; };
const suratKeterangan = proxy({ const suratKeterangan = proxy({
create: { create: {
form: { ...suratKeteranganForm }, form: { ...suratKeteranganForm },
@@ -113,14 +111,19 @@ const suratKeterangan = proxy({
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { // Change to arrow function search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
suratKeterangan.findMany.loading = true; // Use the full path to access the property suratKeterangan.findMany.loading = true; // Use the full path to access the property
suratKeterangan.findMany.page = page; suratKeterangan.findMany.page = page;
suratKeterangan.findMany.search = search;
try { try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.layanan.pelayanansuratketerangan[ const res = await ApiFetch.api.desa.layanan.pelayanansuratketerangan[
"find-many" "find-many"
].get({ ].get({
query: { page, limit }, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
@@ -341,28 +344,34 @@ const pelayananTelunjukSaktiDesa = proxy({
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { // Change to arrow function search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
pelayananTelunjukSaktiDesa.findMany.loading = true; // Use the full path to access the property pelayananTelunjukSaktiDesa.findMany.loading = true; // Use the full path to access the property
pelayananTelunjukSaktiDesa.findMany.page = page; pelayananTelunjukSaktiDesa.findMany.page = page;
pelayananTelunjukSaktiDesa.findMany.search = search;
try { try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.layanan.pelayanantelunjuksaktidesa[ const res = await ApiFetch.api.desa.layanan.pelayanantelunjuksaktidesa[
"find-many" "find-many"
].get({ ].get({
query: { page, limit }, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
pelayananTelunjukSaktiDesa.findMany.data = res.data.data || []; pelayananTelunjukSaktiDesa.findMany.data = res.data.data || [];
pelayananTelunjukSaktiDesa.findMany.total = res.data.total || 0; pelayananTelunjukSaktiDesa.findMany.total = res.data.total || 0;
pelayananTelunjukSaktiDesa.findMany.totalPages = res.data.totalPages || 1; pelayananTelunjukSaktiDesa.findMany.totalPages =
res.data.totalPages || 1;
} else { } else {
console.error("Failed to load telunjuk sakti desa:", res.data?.message); console.error("Failed to load surat keterangan:", res.data?.message);
pelayananTelunjukSaktiDesa.findMany.data = []; pelayananTelunjukSaktiDesa.findMany.data = [];
pelayananTelunjukSaktiDesa.findMany.total = 0; suratKeterangan.findMany.total = 0;
pelayananTelunjukSaktiDesa.findMany.totalPages = 1; suratKeterangan.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading telunjuk sakti desa:", error); console.error("Error loading surat keterangan:", error);
pelayananTelunjukSaktiDesa.findMany.data = []; pelayananTelunjukSaktiDesa.findMany.data = [];
pelayananTelunjukSaktiDesa.findMany.total = 0; pelayananTelunjukSaktiDesa.findMany.total = 0;
pelayananTelunjukSaktiDesa.findMany.totalPages = 1; pelayananTelunjukSaktiDesa.findMany.totalPages = 1;
@@ -410,7 +419,9 @@ const pelayananTelunjukSaktiDesa = proxy({
); );
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
toast.success(result.message || "Telunjuk Sakti Desa berhasil dihapus"); toast.success(
result.message || "Telunjuk Sakti Desa berhasil dihapus"
);
await pelayananTelunjukSaktiDesa.findMany.load(); // refresh list await pelayananTelunjukSaktiDesa.findMany.load(); // refresh list
} else { } else {
toast.error(result.message || "Gagal menghapus telunjuk sakti desa"); toast.error(result.message || "Gagal menghapus telunjuk sakti desa");
@@ -501,7 +512,9 @@ const pelayananTelunjukSaktiDesa = proxy({
} }
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
toast.success(result.message || "Telunjuk Sakti Desa berhasil diupdate"); toast.success(
result.message || "Telunjuk Sakti Desa berhasil diupdate"
);
await pelayananTelunjukSaktiDesa.findMany.load(); // refresh list await pelayananTelunjukSaktiDesa.findMany.load(); // refresh list
return true; return true;
} else { } else {
@@ -522,7 +535,7 @@ const pelayananTelunjukSaktiDesa = proxy({
} }
}, },
}, },
}) });
const pelayananPerizinanBerusaha = proxy({ const pelayananPerizinanBerusaha = proxy({
findById: { findById: {
@@ -596,9 +609,7 @@ const pelayananPerizinanBerusaha = proxy({
} catch (error) { } catch (error) {
console.error("Error fetching pelayanan perizinan berusaha:", error); console.error("Error fetching pelayanan perizinan berusaha:", error);
toast.error( toast.error(
error instanceof Error error instanceof Error ? error.message : "Gagal memuat data"
? error.message
: "Gagal memuat data"
); );
return null; return null;
} }
@@ -713,9 +724,7 @@ const pelayananPendudukNonPermanen = proxy({
} catch (error) { } catch (error) {
console.error("Error fetching pelayanan penduduk non permanen:", error); console.error("Error fetching pelayanan penduduk non permanen:", error);
toast.error( toast.error(
error instanceof Error error instanceof Error ? error.message : "Gagal memuat data"
? error.message
: "Gagal memuat data"
); );
return null; return null;
} }

View File

@@ -56,14 +56,19 @@ const penghargaanState = proxy({
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { // Change to arrow function search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
penghargaanState.findMany.loading = true; // Use the full path to access the property penghargaanState.findMany.loading = true; // Use the full path to access the property
penghargaanState.findMany.page = page; penghargaanState.findMany.page = page;
penghargaanState.findMany.search = search;
try { try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.penghargaan[ const res = await ApiFetch.api.desa.penghargaan[
"find-many" "find-many"
].get({ ].get({
query: { page, limit }, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {

View File

@@ -55,11 +55,39 @@ const category = proxy({
pengumumans: number; pengumumans: number;
}; };
})[], })[],
page: 1,
totalPages: 1,
total: 0,
loading: false, loading: false,
async load() { search: "",
const res = await ApiFetch.api.desa.kategoripengumuman["findMany"].get(); load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
if (res.status === 200) { category.findMany.loading = true; // Use the full path to access the property
category.findMany.data = res.data?.data ?? []; category.findMany.page = page;
category.findMany.search = search;
try {
const res = await ApiFetch.api.desa.kategoripengumuman[
"findMany"
].get({
query: { page, limit },
});
if (res.status === 200 && res.data?.success) {
category.findMany.data = res.data.data || [];
category.findMany.total = res.data.total || 0;
category.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load potensi desa:", res.data?.message);
category.findMany.data = [];
category.findMany.total = 0;
category.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading potensi desa:", error);
category.findMany.data = [];
category.findMany.total = 0;
category.findMany.totalPages = 1;
} finally {
category.findMany.loading = false;
} }
}, },
}, },

View File

@@ -56,9 +56,11 @@ const potensiDesa = proxy({
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { // Change to arrow function search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
potensiDesa.findMany.loading = true; // Use the full path to access the property potensiDesa.findMany.loading = true; // Use the full path to access the property
potensiDesa.findMany.page = page; potensiDesa.findMany.page = page;
potensiDesa.findMany.search = search;
try { try {
const res = await ApiFetch.api.desa.potensi[ const res = await ApiFetch.api.desa.potensi[
"find-many" "find-many"
@@ -298,11 +300,34 @@ const kategoriPotensi = proxy({
isActive: true; isActive: true;
}; };
}>[], }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
const res = await ApiFetch.api.desa.kategoripotensi["findMany"].get(); load: async (page = 1, limit = 10, search = "") => {
if (res.status === 200) { kategoriPotensi.findMany.loading = true; // ✅ Akses langsung via nama path
kategoriPotensi.findMany.data = res.data?.data ?? []; kategoriPotensi.findMany.page = page;
kategoriPotensi.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.kategoripotensi["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriPotensi.findMany.data = res.data.data ?? [];
kategoriPotensi.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriPotensi.findMany.data = [];
kategoriPotensi.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kategori potensi paginated:", err);
kategoriPotensi.findMany.data = [];
kategoriPotensi.findMany.totalPages = 1;
} finally {
kategoriPotensi.findMany.loading = false;
} }
}, },
}, },

View File

@@ -1,9 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) { function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -12,26 +13,35 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
{ {
label: "Pelayanan Surat Keterangan", label: "Pelayanan Surat Keterangan",
value: "pelayanansuratketerangan", value: "pelayanansuratketerangan",
href: "/admin/desa/layanan/pelayanan_surat_keterangan" href: "/admin/desa/layanan/pelayanan_surat_keterangan",
icon: <IconFileText size={18} stroke={1.8} />,
tooltip: "Layanan terkait surat keterangan resmi desa"
}, },
{ {
label: "Pelayanan Perizinan Berusaha", label: "Pelayanan Perizinan Berusaha",
value: "pelayananperizinanusaha", value: "pelayananperizinanusaha",
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha" href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
icon: <IconBuildingStore size={18} stroke={1.8} />,
tooltip: "Layanan untuk izin usaha masyarakat"
}, },
{ {
label: "Pelayanan Telunjuk Sakti Desa", label: "Pelayanan Telunjuk Sakti Desa",
value: "pelayanantelunjuksaktidesa", value: "pelayanantelunjuksaktidesa",
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa" href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
icon: <IconSparkles size={18} stroke={1.8} />,
tooltip: "Layanan inovasi khusus desa"
}, },
{ {
label: "Pelayanan Penduduk Non-Permanent", label: "Pelayanan Penduduk Non-Permanent",
value: "pelayanantelunjuknonpermanent", value: "pelayanannonpermanent",
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent" href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
icon: <IconUsers size={18} stroke={1.8} />,
tooltip: "Pendataan penduduk non-permanent"
} }
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value)
@@ -49,22 +59,63 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}, [pathname]) }, [pathname])
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Layanan</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Layanan</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant='pills'
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -1,61 +1,108 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconNews, IconCategory } from '@tabler/icons-react';
function LayoutTabsBerita({ children }: { children: React.ReactNode }) { function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter();
const pathname = usePathname() const pathname = usePathname();
const tabs = [ const tabs = [
{ {
label: "List Berita", label: "List Berita",
value: "list_berita", value: "list_berita",
href: "/admin/desa/berita/list-berita" href: "/admin/desa/berita/list-berita",
icon: <IconNews size={18} stroke={1.8} />,
tooltip: "Lihat dan kelola semua berita desa"
}, },
{ {
label: "Kategori Berita", label: "Kategori Berita",
value: "kategori_berita", value: "kategori_berita",
href: "/admin/desa/berita/kategori-berita" href: "/admin/desa/berita/kategori-berita",
icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori berita desa"
}, },
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value);
if (tab) { if (tab) {
router.push(tab.href) router.push(tab.href);
}
setActiveTab(value)
} }
setActiveTab(value);
};
useEffect(() => { useEffect(() => {
const match = tabs.find(tab => tab.href === pathname) const match = tabs.find(tab => tab.href === pathname);
if (match) { if (match) {
setActiveTab(match.value) setActiveTab(match.value);
} }
}, [pathname]) }, [pathname]);
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Gallery</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Berita Desa</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant="pills"
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -2,7 +2,16 @@
'use client' 'use client'
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -10,9 +19,10 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function EditKategoriBerita() { function EditKategoriBerita() {
const editState = useProxy(stateDashboardBerita.kategoriBerita) const editState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: editState.update.form.name || '', name: editState.update.form.name || '',
}); });
@@ -23,15 +33,15 @@ function EditKategoriBerita() {
if (!id) return; if (!id) return;
try { try {
const data = await editState.update.load(id); // akses langsung, bukan dari proxy const data = await editState.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name || '',
}); });
} }
} catch (error) { } catch (error) {
console.error("Error loading kategori Berita:", error); console.error('Error loading kategori Berita:', error);
toast.error("Gagal memuat data kategori Berita"); toast.error('Gagal memuat data kategori Berita');
} }
}; };
@@ -44,6 +54,7 @@ function EditKategoriBerita() {
...editState.update.form, ...editState.update.form,
name: formData.name, name: formData.name,
}; };
await editState.update.update(); await editState.update.update();
toast.success('Kategori Berita berhasil diperbarui!'); toast.success('Kategori Berita berhasil diperbarui!');
router.push('/admin/desa/berita/kategori-berita'); router.push('/admin/desa/berita/kategori-berita');
@@ -54,23 +65,56 @@ function EditKategoriBerita() {
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Back Button + Title */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors["blue-button"]} size={30} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Kategori Berita
<Title order={3}>Edit Kategori Berita</Title> </Title>
</Group>
{/* Form Wrapper */}
<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 <TextInput
label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
label={<Text fz={"sm"} fw={"bold"}>Nama Kategori Berita</Text>} required
placeholder="masukkan nama kategori Berita"
/> />
<Button onClick={handleSubmit}>Simpan</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,50 +1,87 @@
'use client' 'use client';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateKategoriBerita() { function CreateKategoriBerita() {
const createState = useProxy(stateDashboardBerita.kategoriBerita) const createState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter(); const router = useRouter();
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
name: "", name: '',
}; };
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create(); await createState.create.create();
resetForm(); resetForm();
router.push("/admin/desa/berita/kategori-berita") router.push('/admin/desa/berita/kategori-berita');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header dengan back button */}
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Berita
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> {/* Form utama */}
<Stack gap={"xs"}> <Paper
<Title order={4}>Create Kategori Berita</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Nama Kategori Berita</Text>} label={<Text fw="bold" fz="sm">Nama Kategori Berita</Text>}
placeholder='Masukkan nama kategori Berita' placeholder="Masukkan nama kategori berita"
value={createState.create.form.name} value={createState.create.form.name || ''}
onChange={(val) => { onChange={(e) => (createState.create.form.name = e.target.value)}
createState.create.form.name = val.target.value; required
}}
/> />
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,25 +1,40 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconEdit, IconSearch, IconTrash } from '@tabler/icons-react'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardBerita from '../../../_state/desa/berita'; import stateDashboardBerita from '../../../_state/desa/berita';
function KategoriBerita() { function KategoriBerita() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Kategori Berita' title="Kategori Berita"
placeholder='pencarian' placeholder="Cari nama kategori berita..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -30,99 +45,155 @@ function KategoriBerita() {
} }
function ListKategoriBerita({ search }: { search: string }) { function ListKategoriBerita({ search }: { search: string }) {
const listDataState = useProxy(stateDashboardBerita.kategoriBerita) const listDataState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
loading,
load,
page,
totalPages,
} = listDataState.findMany;
useEffect(() => { useEffect(() => {
listDataState.findMany.load() load(page, 10, search);
}, []) }, [page, search]);
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
listDataState.delete.delete(selectedId) listDataState.delete.delete(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
load(page, 10, search);
listDataState.findMany.load()
}
} }
};
const filteredData = (listDataState.findMany.data || []).filter(item => { const filteredData = data || [];
const keyword = search.toLowerCase();
return (
item.name.toLowerCase().includes(keyword)
);
});
if (!listDataState.findMany.data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Group justify="space-between" mb="md">
<JudulList <Title order={4}>Daftar Kategori Berita</Title>
title='List Kategori Berita' <Tooltip label="Tambah Kategori Berita" withArrow>
href='/admin/desa/berita/kategori-berita/create' <Button
/> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/berita/kategori-berita/create')
}
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>No</TableTh> <TableTh style={{ width: '10%' }}>No</TableTh>
<TableTh>Nama</TableTh> <TableTh style={{ width: '50%' }}>Nama</TableTh>
<TableTh>Edit</TableTh> <TableTh style={{ width: '20%' }}>Edit</TableTh>
<TableTh>Hapus</TableTh> <TableTh style={{ width: '20%' }}>Hapus</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item, index) => ( {filteredData.length > 0 ? (
filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fz="sm">{index + 1}</Text>
<Text truncate="end" fz={"sm"}>{index + 1}</Text>
</Box>
</TableTd>
<TableTd>{item.name}</TableTd>
<TableTd>
<Button color='green' onClick={() => router.push(`/admin/pendidikan/perpustakaan-digital/kategori-Berita/${item.id}`)}>
<IconEdit size={20} />
</Button>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd>
<Tooltip label="Edit Kategori Berita" withArrow>
<Button <Button
color='red' variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/berita/kategori-berita/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
</Tooltip>
</TableTd>
<TableTd>
<Tooltip label="Hapus Kategori Berita" withArrow>
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
setSelectedId(item.id) setSelectedId(item.id);
setModalHapus(true) setModalHapus(true);
}}> }}
<IconTrash size={20} /> >
<IconTrash size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data kategori berita yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</Paper> </Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleDelete} onConfirm={handleDelete}
text='Apakah anda yakin ingin menghapus kategori Berita ini?' text="Apakah anda yakin ingin menghapus kategori berita ini?"
/> />
</Box> </Box>
) );
} }
export default KategoriBerita; export default KategoriBerita;

View File

@@ -15,7 +15,8 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react"; import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
@@ -24,7 +25,6 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
function EditBerita() { function EditBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const router = useRouter(); const router = useRouter();
@@ -33,29 +33,29 @@ function EditBerita() {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: beritaState.berita.edit.form.judul || '', judul: beritaState.berita.edit.form.judul || "",
deskripsi: beritaState.berita.edit.form.deskripsi || '', deskripsi: beritaState.berita.edit.form.deskripsi || "",
kategoriBeritaId: beritaState.berita.edit.form.kategoriBeritaId || '', kategoriBeritaId: beritaState.berita.edit.form.kategoriBeritaId || "",
content: beritaState.berita.edit.form.content || '', content: beritaState.berita.edit.form.content || "",
imageId: beritaState.berita.edit.form.imageId || '' imageId: beritaState.berita.edit.form.imageId || "",
}); });
// Load berita by id saat pertama kali // Load berita by id saat pertama kali
useEffect(() => { useEffect(() => {
beritaState.kategoriBerita.findMany.load() beritaState.kategoriBerita.findMany.load();
const loadBerita = async () => { const loadBerita = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await stateDashboardBerita.berita.edit.load(id); // akses langsung, bukan dari proxy const data = await stateDashboardBerita.berita.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
judul: data.judul || '', judul: data.judul || "",
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || "",
kategoriBeritaId: data.kategoriBeritaId || '', kategoriBeritaId: data.kategoriBeritaId || "",
content: data.content || '', content: data.content || "",
imageId: data.imageId || '', imageId: data.imageId || "",
}); });
if (data?.image?.link) { if (data?.image?.link) {
@@ -69,31 +69,26 @@ function EditBerita() {
}; };
loadBerita(); loadBerita();
}, [params?.id]); // ✅ hapus beritaState dari dependency }, [params?.id]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
// Update global state with form data
beritaState.berita.edit.form = { beritaState.berita.edit.form = {
...beritaState.berita.edit.form, ...beritaState.berita.edit.form,
judul: formData.judul, ...formData,
deskripsi: formData.deskripsi,
content: formData.content,
kategoriBeritaId: formData.kategoriBeritaId || '',
imageId: formData.imageId // Keep existing imageId if not changed
}; };
// Jika ada file baru, upload
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar"); return toast.error("Gagal upload gambar");
} }
// Update imageId in global state
beritaState.berita.edit.form.imageId = uploaded.id; beritaState.berita.edit.form.imageId = uploaded.id;
} }
@@ -107,87 +102,111 @@ function EditBerita() {
}; };
return ( return (
<Box> <Box px={{ base: "sm", md: "lg" }} py="md">
<Box mb={10}> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors["blue-button"]} size={30} /> <Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Berita
<Title order={3}>Edit Berita</Title> </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 <TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.judul} value={formData.judul}
onChange={(e) => setFormData({ ...formData, judul: e.target.value })} onChange={(e) =>
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>} setFormData({ ...formData, judul: e.target.value })
placeholder="masukkan judul" }
required
/> />
<TextInput <TextInput
label="Deskripsi"
placeholder="Masukkan deskripsi"
value={formData.deskripsi} value={formData.deskripsi}
onChange={(e) => setFormData({ ...formData, deskripsi: e.target.value })} onChange={(e) =>
label={<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>} setFormData({ ...formData, deskripsi: e.target.value })
placeholder="masukkan deskripsi" }
required
/> />
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box> Gambar Berita
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error("File tidak valid, gunakan format gambar")}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ "image/*": [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag gambar ke sini atau klik untuk pilih file
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Maksimal 5MB dan harus format gambar Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
radius="md"
style={{ style={{
maxWidth: '100%', maxHeight: 220,
maxHeight: '200px', objectFit: "contain",
objectFit: 'contain', border: `1px solid ${colors["blue-button"]}`,
borderRadius: '8px',
border: '1px solid #ddd',
}} }}
/> />
</Box> </Box>
)} )}
</Box>
</Box>
</Box>
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text> <Text fz="sm" fw="bold">
Konten
</Text>
<EditEditor <EditEditor
value={formData.content} value={formData.content}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -199,13 +218,15 @@ function EditBerita() {
<Select <Select
value={formData.kategoriBeritaId} value={formData.kategoriBeritaId}
onChange={(val) => setFormData({ ...formData, kategoriBeritaId: val || "" })} onChange={(val) =>
label={<Text fw={"bold"} fz={"sm"}>Kategori</Text>} setFormData({ ...formData, kategoriBeritaId: val || "" })
placeholder='Pilih kategori' }
label="Kategori"
placeholder="Pilih kategori"
data={ data={
beritaState.kategoriBerita.findMany.data?.map((v) => ({ beritaState.kategoriBerita.findMany.data?.map((v) => ({
value: v.id, value: v.id,
label: v.name label: v.name,
})) || [] })) || []
} }
clearable clearable
@@ -214,7 +235,20 @@ function EditBerita() {
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined} error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/> />
<Button onClick={handleSubmit}>Edit Berita</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,9 +1,8 @@
'use client' 'use client'
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -12,104 +11,143 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
function DetailBerita() { function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita) const beritaState = useProxy(stateDashboardBerita);
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
beritaState.berita.findUnique.load(params?.id as string) beritaState.berita.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
beritaState.berita.delete.byId(selectedId) beritaState.berita.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/berita/list-berita") router.push("/admin/desa/berita/list-berita");
}
} }
};
if (!beritaState.berita.findUnique.data) { if (!beritaState.berita.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={40} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = beritaState.berita.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Back */}
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Berita</Text>
{beritaState.berita.findUnique.data ? (
<Paper key={beritaState.berita.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Kategori</Text>
<Text fz={"lg"}>{beritaState.berita.findUnique.data?.kategoriBerita?.name}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Judul</Text>
<Text fz={"lg"}>{beritaState.berita.findUnique.data?.judul}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
<Text fz={"lg"} >{beritaState.berita.findUnique.data?.deskripsi}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 150, md: 150, lg: 150 }} src={beritaState.berita.findUnique.data?.image?.link} alt="gambar" />
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Konten</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: beritaState.berita.findUnique.data?.content }} />
</Box>
<Flex gap={"xs"} mt={10}>
<Button <Button
onClick={() => { variant="subtle"
if (beritaState.berita.findUnique.data) { onClick={() => router.back()}
setSelectedId(beritaState.berita.findUnique.data.id); leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
setModalHapus(true); mb={15}
}
}}
disabled={beritaState.berita.delete.loading || !beritaState.berita.findUnique.data}
color={"red"}
> >
<IconX size={20} /> Kembali
</Button> </Button>
{/* Detail Berita */}
<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 Berita
</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.kategoriBerita?.name || '-'}</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">Deskripsi</Text>
<Text fz="md" c="dimmed">{data.deskripsi || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.judul || 'Gambar Berita'}
w={200}
h={200}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">Konten</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
</Box>
{/* Action Button */}
<Group gap="sm">
<Tooltip label="Hapus Berita" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (beritaState.berita.findUnique.data) { setSelectedId(data.id);
router.push(`/admin/desa/berita/list-berita/${beritaState.berita.findUnique.data.id}/edit`); setModalHapus(true);
}
}} }}
disabled={!beritaState.berita.findUnique.data} variant="light"
color={"green"} radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Berita" withArrow position="top">
<Button
color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */} {/* Modal Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus berita ini?' text="Apakah Anda yakin ingin menghapus berita ini?"
/> />
</Box> </Box>
); );

View File

@@ -3,7 +3,19 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -12,38 +24,33 @@ import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
export default function CreateBerita() { export default function CreateBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter() const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
beritaState.kategoriBerita.findMany.load() beritaState.kategoriBerita.findMany.load();
}, []); }, []);
const resetForm = () => { const resetForm = () => {
// Reset state di valtio
beritaState.berita.create.form = { beritaState.berita.create.form = {
judul: "", judul: '',
deskripsi: "", deskripsi: '',
kategoriBeritaId: "", kategoriBeritaId: '',
imageId: "", imageId: '',
content: "", content: '',
}; };
// Reset state lokal
setPreviewImage(null); setPreviewImage(null);
setFile(null); setFile(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) { if (!file) {
return toast.warn("Pilih file gambar terlebih dahulu"); return toast.warn('Silakan pilih file gambar terlebih dahulu');
} }
// Upload gambar dulu
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
name: file.name, name: file.name,
@@ -51,40 +58,55 @@ export default function CreateBerita() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar"); return toast.error('Gagal mengunggah gambar, silakan coba lagi');
} }
// Simpan ID gambar ke form
beritaState.berita.create.form.imageId = uploaded.id; beritaState.berita.create.form.imageId = uploaded.id;
// Submit data berita
await beritaState.berita.create.create(); await beritaState.berita.create.create();
// Reset form setelah submit
resetForm(); resetForm();
router.push("/admin/desa/berita/list-berita") router.push('/admin/desa/berita/list-berita');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header dengan tombol kembali */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Tambah Berita
<Title order={3}>Create Berita</Title> </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 <TextInput
label="Judul"
placeholder="Masukkan judul berita"
value={beritaState.berita.create.form.judul} value={beritaState.berita.create.form.judul}
onChange={(val) => { onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
beritaState.berita.create.form.judul = val.target.value; required
}}
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>}
placeholder="masukkan judul"
/> />
<Select <Select
label={<Text fz={"sm"} fw={"bold"}>Kategori</Text>} label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
data={beritaState.kategoriBerita.findMany.data.map((item) => ({ data={beritaState.kategoriBerita.findMany.data.map((item) => ({
label: item.name, label: item.name,
@@ -93,85 +115,83 @@ export default function CreateBerita() {
value={beritaState.berita.create.form.kategoriBeritaId || null} value={beritaState.berita.create.form.kategoriBeritaId || null}
onChange={(val: string | null) => { onChange={(val: string | null) => {
if (val) { if (val) {
const selected = beritaState.kategoriBerita.findMany.data?.find((item) => item.id === val); const selected = beritaState.kategoriBerita.findMany.data?.find(
(item) => item.id === val
);
if (selected) { if (selected) {
beritaState.berita.create.form.kategoriBeritaId = selected.id; beritaState.berita.create.form.kategoriBeritaId = selected.id;
} }
} else { } else {
beritaState.berita.create.form.kategoriBeritaId = ""; beritaState.berita.create.form.kategoriBeritaId = '';
} }
}} }}
searchable searchable
clearable clearable
nothingFoundMessage="Tidak ditemukan" nothingFoundMessage="Tidak ditemukan"
required
/> />
<TextInput <TextInput
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi berita"
value={beritaState.berita.create.form.deskripsi} value={beritaState.berita.create.form.deskripsi}
onChange={(val) => { onChange={(e) => (beritaState.berita.create.form.deskripsi = e.target.value)}
beritaState.berita.create.form.deskripsi = val.target.value;
}}
label={<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>}
placeholder="masukkan deskripsi"
/> />
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box> Gambar Berita
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
radius="md"
style={{ style={{
maxWidth: '100%', maxHeight: 200,
maxHeight: '200px',
objectFit: 'contain', objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd', border: '1px solid #ddd',
}} }}
/> />
</Box> </Box>
)} )}
</Box>
</Box>
</Box>
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text> <Text fz="sm" fw="bold" mb={6}>
Konten
</Text>
<CreateEditor <CreateEditor
value={beritaState.berita.create.form.content} value={beritaState.berita.create.form.content}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -179,7 +199,21 @@ export default function CreateBerita() {
}} }}
/> />
</Box> </Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan Berita</Button>
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,6 +1,25 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Grid, GridCol, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -9,15 +28,13 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import stateDashboardBerita from '../../../_state/desa/berita'; import stateDashboardBerita from '../../../_state/desa/berita';
function Berita() { function Berita() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Berita' title="Berita"
placeholder='pencarian' placeholder="Cari judul atau kategori berita..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -28,103 +45,125 @@ function Berita() {
} }
function ListBerita({ search }: { search: string }) { function ListBerita({ search }: { search: string }) {
const beritaState = useProxy(stateDashboardBerita) const beritaState = useProxy(stateDashboardBerita);
const router = useRouter() const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = beritaState.berita.findMany;
const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
// Fetch data when page or search changes
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, search);
}, [page, search]); }, [page, search]);
if (loading || !data) { if (loading || !data) {
return <Skeleton h={500} />; return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
} }
const filteredData = data || []; const filteredData = data || [];
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors["white-1"]} p={"md"}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Group justify="space-between" mb="md">
<Grid> <Title order={4}>Daftar Berita</Title>
<GridCol span={{ base: 12, md: 11 }}> <Tooltip label="Tambah Berita" withArrow>
<Text fz={"xl"} fw={"bold"}>
List Berita
</Text>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button <Button
onClick={() => router.push("/admin/desa/berita/list-berita/create")} leftSection={<IconCircleDashedPlus size={18} />}
bg={colors["blue-button"]} color="blue"
variant="light"
onClick={() => router.push('/admin/desa/berita/list-berita/create')}
> >
<IconCircleDashedPlus size={25} /> Tambah Baru
</Button> </Button>
</GridCol> </Tooltip>
</Grid> </Group>
<Box style={{ overflowX: "auto" }}>
<Table <Box style={{ overflowX: 'auto' }}>
striped <Table highlightOnHover>
withRowBorders
withTableBorder
style={{ minWidth: "700px" }}
>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w={250}>Judul</TableTh> <TableTh style={{ width: '30%' }}>Judul</TableTh>
<TableTh w={250}>Kategori</TableTh> <TableTh style={{ width: '20%' }}>Kategori</TableTh>
<TableTh w={250}>Image</TableTh> <TableTh style={{ width: '25%' }}>Gambar</TableTh>
<TableTh w={200}>Detail</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '30%' }}>
<Box w={100}> <Text fw={500} truncate="end" lineClamp={1}>
<Text truncate="end" fz={"sm"}>
{item.judul} {item.judul}
</Text> </Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Text fz="sm" c="dimmed">
{item.kategoriBerita?.name || '-'}
</Text>
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box
w={80}
h={80}
style={{ borderRadius: 8, overflow: 'hidden' }}
>
{item.image?.link ? (
<Image src={item.image.link} alt="gambar" fit="cover" />
) : (
<Box bg={colors['blue-button']} w="100%" h="100%" />
)}
</Box> </Box>
</TableTd> </TableTd>
<TableTd>{item.kategoriBerita?.name}</TableTd> <TableTd style={{ width: '15%' }}>
<TableTd>
<Image w={100} src={item.image?.link} alt="gambar" />
</TableTd>
<TableTd>
<Button <Button
bg={"green"} variant="light"
color="blue"
onClick={() => onClick={() =>
router.push(`/admin/desa/berita/list-berita/${item.id}`) router.push(`/admin/desa/berita/list-berita/${item.id}`)
} }
> >
<IconDeviceImacCog size={25} /> <IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data berita yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>
); );
} }
export default Berita; export default Berita;

View File

@@ -1,161 +0,0 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconImageInPicture, 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';
function EditFoto() {
const fotoState = useProxy(stateGallery.foto)
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({
name: fotoState.update.form.name || '',
deskripsi: fotoState.update.form.deskripsi || '',
imagesId: fotoState.update.form.imagesId || ''
});
useEffect(() => {
const loadFoto = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await fotoState.update.load(id);
if (data) {
setFormData({
name: data.name || '',
deskripsi: data.deskripsi || '',
imagesId: data.imageGalleryFoto?.id || ''
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error('Error loading foto:', error);
toast.error('Gagal memuat data foto');
}
};
loadFoto();
}, [params?.id]);
const handleSubmit = async () => {
try {
fotoState.update.form = {
...fotoState.update.form,
name: formData.name,
deskripsi: formData.deskripsi,
imagesId: formData.imagesId
};
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");
}
fotoState.update.form.imagesId = uploaded.id;
}
await fotoState.update.update();
toast.success('Foto berhasil diperbarui!');
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error updating foto:', error);
toast.error('Terjadi kesalahan saat memperbarui foto');
}
};
return (
<Box>
<Box mb={10}>
<Button onClick={() => router.back()} variant='subtle' color={'blue'}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}>
<Stack gap={"xs"}>
<Title order={4}>Edit Foto</Title>
<TextInput
label={<Text fw={"bold"} fz={"sm"}>Judul Foto</Text>}
placeholder='Masukkan judul foto'
value={formData.name}
onChange={(e) =>
(formData.name = e.target.value)
}
/>
<Box>
<Text>Upload Foto</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview
}
}}
onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2} // Maks 5MB
accept={{ 'image/*': [] }}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group>
</Dropzone>
{previewImage ? (
<Image alt="" src={previewImage} w={200} h={200} />
) : (
<Center w={200} h={200} bg={"gray"}>
<IconImageInPicture />
</Center>
)}
</Box>
<Box>
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
<EditEditor
value={fotoState.update.form.deskripsi}
onChange={(val) => {
fotoState.update.form.deskripsi = val;
}}
/>
</Box>
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditFoto;

View File

@@ -1,112 +0,0 @@
'use client'
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import React from 'react';
import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useShallowEffect } from '@mantine/hooks';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react';
import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
function DetailFoto() {
const fotoState = useProxy(stateGallery.foto)
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null)
const params = useParams()
const router = useRouter()
useShallowEffect(() => {
fotoState.findUnique.load(params?.id as string)
}, [])
const handleHapus = () => {
if (selectedId) {
fotoState.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
router.push("/admin/desa/gallery/foto")
}
}
if (!fotoState.findUnique.data) {
return (
<Stack py={10}>
<Skeleton h={500} />
</Stack>
)
}
return (
<Box>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Foto</Text>
{fotoState.findUnique.data ? (
<Paper key={fotoState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Judul</Text>
<Text fz={"lg"}>{fotoState.findUnique.data?.name}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Tanggal Foto</Text>
<Text fz={"lg"}>{new Date(fotoState.findUnique.data?.createdAt).toDateString()}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: fotoState.findUnique.data?.deskripsi }} />
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 300, md: 350}} src={fotoState.findUnique.data?.imageGalleryFoto?.link} alt="gambar" />
</Box>
<Flex gap={"xs"} mt={10}>
<Button
onClick={() => {
if (fotoState.findUnique.data) {
setSelectedId(fotoState.findUnique.data.id);
setModalHapus(true);
}
}}
disabled={fotoState.delete.loading || !fotoState.findUnique.data}
color={"red"}
>
<IconX size={20} />
</Button>
<Button
onClick={() => {
if (fotoState.findUnique.data) {
router.push(`/admin/desa/gallery/foto/${fotoState.findUnique.data.id}/edit`);
}
}}
disabled={!fotoState.findUnique.data}
color={"green"}
>
<IconEdit size={20} />
</Button>
</Flex>
</Stack>
</Paper>
) : null}
</Stack>
</Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus berita ini?'
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -1,147 +0,0 @@
'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
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';
function CreateFoto() {
const fotoState = useProxy(stateGallery.foto)
const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
fotoState.create.form = {
name: "",
deskripsi: "",
imagesId: "",
};
setPreviewImage(null)
setFile(null)
};
const handleSubmit = async () => {
if (!file) {
return toast.warn("Pilih file gambar terlebih dahulu");
}
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");
}
fotoState.create.form.imagesId = uploaded.id;
await fotoState.create.create();
resetForm();
router.push("/admin/desa/gallery/foto")
};
return (
<Box>
<Box mb={10}>
<Button onClick={() => router.back()} variant='subtle' color={'blue'}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}>
<Stack gap={"xs"}>
<Title order={4}>Create Foto</Title>
<TextInput
label={<Text fw={"bold"} fz={"sm"}>Judul Foto</Text>}
placeholder='Masukkan judul foto'
value={fotoState.create.form.name}
onChange={(val) => {
fotoState.create.form.name = val.target.value;
}}
/>
<Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text>
<Box>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview
}
}}
onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2} // Maks 5MB
accept={{ 'image/*': [] }}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group>
</Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && (
<Box mt="sm">
<Image
src={previewImage}
alt="Preview"
style={{
maxWidth: '100%',
maxHeight: '200px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
/>
</Box>
)}
</Box>
</Box>
<Box>
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
<CreateEditor
value={fotoState.create.form.deskripsi}
onChange={(val) => {
fotoState.create.form.deskripsi = val;
}}
/>
</Box>
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateFoto;

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import colors from "@/con/colors";
import stateFileStorage from "@/state/state-list-image"; import stateFileStorage from "@/state/state-list-image";
import { import {
ActionIcon, ActionIcon,
Box, Box,
Card,
Flex, Flex,
Group, Group,
Image, Image,
@@ -13,7 +13,8 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
@@ -29,95 +30,128 @@ export default function ListImage() {
}, []); }, []);
let timeOut: NodeJS.Timer; let timeOut: NodeJS.Timer;
return ( return (
<Stack p={"lg"}> <Stack p="lg" gap="lg">
<Flex justify="space-between"> <Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={3}>List Foto</Title> <Title order={2} fw={700}>
Galeri Foto
</Title>
<TextInput <TextInput
radius={"lg"} radius="xl"
leftSection={<IconSearch />} size="md"
placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={ rightSection={
<ActionIcon <ActionIcon
variant="transparent" variant="light"
onClick={() => { color="gray"
stateFileStorage.load(); radius="xl"
}} onClick={() => stateFileStorage.load()}
> >
<IconX /> <IconX size={18} />
</ActionIcon> </ActionIcon>
} }
placeholder="Pencarian"
onChange={(e) => { onChange={(e) => {
if (timeOut) clearTimeout(timeOut); if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => { timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value }); stateFileStorage.load({ search: e.target.value });
}, 200); }, 300);
}} }}
/> />
</Flex> </Flex>
<Paper bg={colors['white-1']} p={'md'}>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid <SimpleGrid
cols={{ cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
base: 3, spacing="md"
md: 5, verticalSpacing="md"
lg: 10,
}}
> >
{list && {list.map((v, k) => (
list.map((v, k) => { <Card
return ( key={k}
<Paper key={k} shadow="sm"> withBorder
<Stack pos={"relative"} gap={0} justify="space-between"> radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div <motion.div
onClick={() => { onClick={() => {
// copy to clipboard
navigator.clipboard.writeText(v.url); navigator.clipboard.writeText(v.url);
toast("Berhasil disalin"); toast("Tautan foto berhasil disalin");
}} }}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.8 }} whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
> >
<Image <Image
h={100} src={`${v.url}?size=200`}
src={v.url + "?size=100"}
alt={v.name} alt={v.name}
radius="md"
h={120}
fit="cover" fit="cover"
loading="lazy" loading="lazy"
style={{
objectFit: "cover",
objectPosition: "center",
}}
/> />
</motion.div> </motion.div>
<Box p={"md"} h={54}>
<Text lineClamp={2} fz={"xs"}> <Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name} {v.name}
</Text> </Text>
</Box> </Box>
<Group justify="end">
<IconTrash <Group justify="space-between" align="center" pt="xs">
<Tooltip label="Hapus foto" withArrow>
<ActionIcon
variant="subtle"
color="red" color="red"
radius="md"
onClick={() => { onClick={() => {
stateFileStorage.del({ name: v.name }).finally(() => { stateFileStorage
toast("Berhasil dihapus"); .del({ name: v.name })
}); .finally(() => toast("Foto berhasil dihapus"));
}} }}
/> >
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Card>
); ))}
})}
</SimpleGrid> </SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper> </Paper>
{total && (
{total && total > 1 && (
<Flex justify="center">
<Pagination <Pagination
total={total} total={total}
onChange={(e) => { size="md"
stateFileStorage.page = e; radius="md"
withEdges
onChange={(page) => {
stateFileStorage.page = page;
stateFileStorage.load(); stateFileStorage.load();
}} }}
/> />
</Flex>
)} )}
</Stack> </Stack>
); );

View File

@@ -1,9 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconPhoto, IconVideo } from '@tabler/icons-react';
function LayoutTabsGallery({ children }: { children: React.ReactNode }) { function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -12,16 +13,21 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
{ {
label: "Foto", label: "Foto",
value: "foto", value: "foto",
href: "/admin/desa/gallery/foto" href: "/admin/desa/gallery/foto",
icon: <IconPhoto size={18} stroke={1.8} />,
tooltip: "Kelola foto-foto galeri desa"
}, },
{ {
label: "Video", label: "Video",
value: "video", value: "video",
href: "/admin/desa/gallery/video" href: "/admin/desa/gallery/video",
icon: <IconVideo size={18} stroke={1.8} />,
tooltip: "Kelola video galeri desa"
}, },
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value)
@@ -39,22 +45,62 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
}, [pathname]) }, [pathname])
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Gallery</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Gallery</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant='pills'
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -3,7 +3,16 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -13,8 +22,8 @@ import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
function EditVideo() { function EditVideo() {
const router = useRouter(); const router = useRouter();
const videoState = useProxy(stateGallery.video) const videoState = useProxy(stateGallery.video);
const params = useParams() const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -66,27 +75,36 @@ function EditVideo() {
console.error('Error updating video:', error); console.error('Error updating video:', error);
toast.error('Terjadi kesalahan saat memperbarui video'); toast.error('Terjadi kesalahan saat memperbarui video');
} }
} };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> <Group mb="md">
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={25} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> Edit Video
<Stack gap={"xs"}> </Title>
<Title order={4}>Edit Video</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 <TextInput
label={<Text fw={"bold"} fz={"sm"}>Judul Video</Text>} label="Judul Video"
placeholder='Masukkan judul video' placeholder="Masukkan judul video"
value={formData.name} value={formData.name}
onChange={(val) => { onChange={(e) => setFormData({ ...formData, name: e.target.value })}
setFormData({ ...formData, name: val.target.value }); required
}}
/> />
<Box> <Box>
@@ -94,36 +112,46 @@ function EditVideo() {
label="Link Video YouTube" label="Link Video YouTube"
placeholder="https://www.youtube.com/watch?v=abc123" placeholder="https://www.youtube.com/watch?v=abc123"
value={formData.linkVideo} value={formData.linkVideo}
onChange={(e) => { onChange={(e) => setFormData({ ...formData, linkVideo: e.currentTarget.value })}
setFormData({ ...formData, linkVideo: e.currentTarget.value });
}}
required required
/> />
{embedLink && ( {embedLink && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<iframe <iframe
className="rounded" className="rounded"
width="100%" width="100%"
height="200" height="220"
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
></iframe> ></iframe>
</Box>
)} )}
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"sm"}>Deskripsi Video</Text> <Title order={6} fw="bold" fz="sm" mb={6}>
Deskripsi Video
</Title>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(val) => { onChange={(val) => setFormData({ ...formData, deskripsi: val })}
setFormData({ ...formData, deskripsi: val });
}}
/> />
</Box> </Box>
<Group> <Group justify="right">
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -2,107 +2,145 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Group, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailVideo() { function DetailVideo() {
const videoState = useProxy(stateGallery.video) const videoState = useProxy(stateGallery.video);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
videoState.findUnique.load(params?.id as string) videoState.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
videoState.delete.byId(selectedId) videoState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/gallery/video") router.push("/admin/desa/gallery/video");
}
} }
};
if (!videoState.findUnique.data) { if (!videoState.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = videoState.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Kembali */}
<Button variant="subtle" onClick={() => router.back()}> <Button
<IconArrowBack color={colors['blue-button']} size={25} /> variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button> </Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}> {/* Detail Video */}
<Stack> <Paper
<Text fz={"xl"} fw={"bold"}>Detail Video</Text> withBorder
{videoState.findUnique.data ? ( w={{ base: "100%", md: "50%" }}
<Paper key={videoState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}> bg={colors['white-1']}
<Stack gap={"xs"}> p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Video
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Judul</Text> <Text fz="lg" fw="bold">Judul</Text>
<Text fz={"lg"}>{videoState.findUnique.data?.name}</Text> <Text fz="md" c="dimmed">{data?.name || '-'}</Text>
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Video</Text> <Text fz="lg" fw="bold">Video</Text>
<Box component="iframe" {data?.linkVideo ? (
src={convertToEmbedUrl(videoState.findUnique.data?.linkVideo)} <Box
component="iframe"
src={convertToEmbedUrl(data.linkVideo)}
width="100%" width="100%"
height={300} height={300}
allowFullScreen allowFullScreen
style={{ borderRadius: 8 }} style={{ borderRadius: 8 }}
/> />
) : (
<Text fz="sm" c="dimmed">Tidak ada video</Text>
)}
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Tanggal Video</Text> <Text fz="lg" fw="bold">Tanggal Video</Text>
<Text fz={"lg"}>{new Date(videoState.findUnique.data?.createdAt).toDateString()}</Text> <Text fz="md" c="dimmed">
{data?.createdAt ? new Date(data.createdAt).toDateString() : '-'}
</Text>
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: videoState.findUnique.data?.deskripsi }} /> {data?.deskripsi ? (
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
/>
) : (
<Text fz="sm" c="dimmed">Tidak ada deskripsi</Text>
)}
</Box> </Box>
<Flex gap={"xs"} mt={10}>
{/* Tombol Aksi */}
<Group gap="sm">
<Tooltip label="Hapus Video" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (videoState.findUnique.data) { setSelectedId(data.id);
setSelectedId(videoState.findUnique.data.id);
setModalHapus(true); setModalHapus(true);
}
}} }}
disabled={videoState.delete.loading || !videoState.findUnique.data} variant="light"
color={"red"} radius="md"
size="md"
> >
<IconX size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Video" withArrow position="top">
<Button <Button
onClick={() => { color="green"
if (videoState.findUnique.data) { onClick={() =>
router.push(`/admin/desa/gallery/video/${videoState.findUnique.data.id}/edit`); router.push(`/admin/desa/gallery/video/${data.id}/edit`)
} }
}} variant="light"
disabled={!videoState.findUnique.data} radius="md"
color={"green"} size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
@@ -111,17 +149,16 @@ function DetailVideo() {
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus berita ini?' text="Apakah Anda yakin ingin menghapus video ini?"
/> />
</Box> </Box>
); );
function convertToEmbedUrl(youtubeUrl: string): string { function convertToEmbedUrl(youtubeUrl: string): string {
try { try {
const url = new URL(youtubeUrl); const url = new URL(youtubeUrl);
const videoId = url.searchParams.get("v"); const videoId = url.searchParams.get("v");
if (!videoId) return youtubeUrl; if (!videoId) return youtubeUrl;
return `https://www.youtube.com/embed/${videoId}`; return `https://www.youtube.com/embed/${videoId}`;
} catch (err) { } catch (err) {
console.error("Error converting YouTube URL to embed:", err); console.error("Error converting YouTube URL to embed:", err);

View File

@@ -1,8 +1,18 @@
'use client' 'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -10,77 +20,104 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '../../lib/youtube-utils'; import { convertYoutubeUrlToEmbed } from '../../lib/youtube-utils';
function CreateVideo() { function CreateVideo() {
const videoState = useProxy(stateGallery.video) const videoState = useProxy(stateGallery.video);
const router = useRouter(); const router = useRouter();
const [link, setLink] = useState(""); const [link, setLink] = useState('');
const embedLink = convertYoutubeUrlToEmbed(link); const embedLink = convertYoutubeUrlToEmbed(link);
const resetForm = () => { const resetForm = () => {
videoState.create.form = { videoState.create.form = {
name: "", name: '',
deskripsi: "", deskripsi: '',
linkVideo: "", linkVideo: '',
}; };
setLink('');
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!embedLink) { if (!embedLink) {
toast.error("Link YouTube tidak valid. Pastikan formatnya benar."); toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
return; return;
} }
videoState.create.form.linkVideo = embedLink; // pastikan diset di sini juga (jaga-jaga) videoState.create.form.linkVideo = embedLink;
await videoState.create.create(); await videoState.create.create();
resetForm(); resetForm();
router.push("/admin/desa/gallery/video"); router.push('/admin/desa/gallery/video');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header Back Button + Title */}
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Video
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> {/* Card Form */}
<Stack gap={"xs"}> <Paper
<Title order={4}>Create Video</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Judul Video</Text>} label="Judul Video"
placeholder='Masukkan judul video' placeholder="Masukkan judul video"
value={videoState.create.form.name} value={videoState.create.form.name}
onChange={(val) => {
videoState.create.form.name = val.target.value;
}}
/>
<Box>
<Stack gap={"xs"}>
<TextInput
label="Link Video YouTube"
placeholder="https://www.youtube.com/watch?v=abc123"
value={link}
onChange={(e) => { onChange={(e) => {
setLink(e.currentTarget.value); videoState.create.form.name = e.currentTarget.value;
}} }}
required required
/> />
{/* Link YouTube */}
<TextInput
label="Link Video YouTube"
placeholder="https://www.youtube.com/watch?v=abc123"
value={link}
onChange={(e) => setLink(e.currentTarget.value)}
required
/>
{/* Preview Video */}
{embedLink && ( {embedLink && (
<Box mt="sm">
<iframe <iframe
style={{ borderRadius: 10, width: "100%", height: 400 }} style={{
borderRadius: 10,
width: '100%',
height: 400,
border: '1px solid #ddd',
}}
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
></iframe> ></iframe>
)}
</Stack>
</Box> </Box>
)}
{/* Deskripsi */}
<Box> <Box>
<Text fw={"bold"} fz={"sm"}>Deskripsi Video</Text> <Text fw="bold" fz="sm" mb={6}>
Deskripsi Video
</Text>
<CreateEditor <CreateEditor
value={videoState.create.form.deskripsi} value={videoState.create.form.deskripsi}
onChange={(val) => { onChange={(val) => {
@@ -88,8 +125,21 @@ function CreateVideo() {
}} }}
/> />
</Box> </Box>
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> {/* Button Submit */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,13 +1,30 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import stateGallery from '../../../_state/desa/gallery'; import stateGallery from '../../../_state/desa/gallery';
function Video() { function Video() {
@@ -15,8 +32,8 @@ function Video() {
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Posisi Organisasi' title='Video'
placeholder='pencarian' placeholder='Cari judul atau deskripsi video...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -29,6 +46,7 @@ function Video() {
function ListVideo({ search }: { search: string }) { function ListVideo({ search }: { search: string }) {
const videoState = useProxy(stateGallery.video) const videoState = useProxy(stateGallery.video)
const router = useRouter(); const router = useRouter();
const { const {
data, data,
page, page,
@@ -41,72 +59,104 @@ function ListVideo({ search }: { search: string }) {
load(page, 10, search) load(page, 10, search)
}, [page, search]) }, [page, search])
const filteredData = (data || []) const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
<Box py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Box> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<JudulList <Group justify="space-between" mb="md">
title='List Video' <Title order={4}>Daftar Video</Title>
href='/admin/desa/gallery/video/create' <Tooltip label="Tambah Video Baru" withArrow>
/> <Button
<Table striped withTableBorder withRowBorders> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/video/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Judul Video</TableTh> <TableTh style={{ width: '25%' }}>Judul Video</TableTh>
<TableTh>Tanggal Video</TableTh> <TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh>Deskripsi Video</TableTh> <TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '25%' }}>
<Box w={200}> <Box w={200}>
<Text lineClamp={1}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box> </Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%' }}>
<TableTd>
<Box w={200}> <Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', { {new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
})} })}
</Text>
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '30%' }}>
<Box w={200}> <Box w={200}>
<Text lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '15%' }}>
<Button onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}> <Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
>
<IconDeviceImac size={20} /> <IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada video yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -3,7 +3,7 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -49,52 +49,74 @@ function EditPelayananPendudukNonPermanent() {
} }
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Stack gap="xs">
<Box> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant={'subtle'} <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
onClick={() => router.back()} <IconArrowBack color={colors['blue-button']} size={24} />
>
<IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
</Box> </Tooltip>
<Box> <Title order={4} ml="sm" c="dark">
<Paper bg={colors['white-1']} p={'md'} radius={10} w={{ base: '100%', md: '50%' }}> Edit Pelayanan Penduduk Non Permanent
<Stack gap={'xs'}> </Title>
</Group>
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Penduduk Non Permanent</Title> <Title order={3}>Edit Pelayanan Penduduk Non Permanent</Title>
<Text fw={"bold"}>Judul</Text>
{/* Nama Field */}
<TextInput <TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name} value={formData.name}
onChange={(val) => { onChange={(e) =>
setFormData({ setFormData({ ...formData, name: e.target.value })
...formData, }
name: val.target.value, required
})
}}
/>
<Text fw={"bold"}>Deskripsi</Text>
<EditEditor
value={formData.deskripsi}
onChange={(val) => {
setFormData({
...formData,
deskripsi: val,
})
}}
/> />
{/* Posisi Field */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) => {
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }));
}}
/>
</Box>
{/* Submit Button */}
<Group> <Group>
<Button <Button
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit} onClick={handleSubmit}
loading={statePendudukNonPermanent.update.loading} loading={statePendudukNonPermanent.update.loading}
disabled={!formData.name}
> >
Submit {statePendudukNonPermanent.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button
variant="outline"
onClick={() => router.back()}
disabled={statePendudukNonPermanent.update.loading}
>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
</Box>
</Stack> </Stack>
</Box> </Box>
); );

View File

@@ -1,51 +1,103 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Grid, GridCol, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Divider,
Grid,
GridCol,
Paper,
Skeleton,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
function SuratKeterangan() { function PelayananPendudukNonPermanent() {
const router = useRouter() const router = useRouter();
const pelayananPendudukNonPermanen = useProxy(stateLayananDesa.pelayananPendudukNonPermanen) const pelayananPendudukNonPermanen = useProxy(
stateLayananDesa.pelayananPendudukNonPermanen
);
useShallowEffect(() => { useShallowEffect(() => {
pelayananPendudukNonPermanen.findById.load('1') pelayananPendudukNonPermanen.findById.load('1');
}, []) }, []);
if (!pelayananPendudukNonPermanen.findById.data) { if (!pelayananPendudukNonPermanen.findById.data) {
return ( return (
<Stack> <Stack align="center" justify="center" py="xl">
<Skeleton radius={10} h={800} /> <Skeleton radius="md" height={800} />
</Stack> </Stack>
)
}
return (
<Box py={10}>
<Paper bg={colors['white-1']} p={'md'}>
<Paper bg={colors['BG-trans']} p={'md'}>
<Box py={15}>
<Stack gap={"xs"}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}>
<Text fz={"h4"} fw={"bold"}>Preview Pelayanan Perizinan Berusaha</Text>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button bg={colors['blue-button']} onClick={() => router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent/edit')}>
<IconEdit size={16} />
</Button>
</GridCol>
</Grid>
</Stack>
</Box>
<Text fz={{ base: "h4", md: 'h2' }} fw={"bold"}>{pelayananPendudukNonPermanen.findById.data.name}</Text>
<Text py={10} ta={"justify"} fz={{ base: "sm", md: 'h3' }} dangerouslySetInnerHTML={{ __html: pelayananPendudukNonPermanen.findById.data.deskripsi }} />
</Paper>
</Paper>
</Box>
); );
} }
export default SuratKeterangan; const data = pelayananPendudukNonPermanen.findById.data;
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md">
{/* Header */}
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>
Preview Pelayanan Penduduk Non Permanen
</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Pelayanan" withArrow>
<Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() =>
router.push(
'/admin/desa/layanan/pelayanan_penduduk_non_permanent/edit'
)
}
>
Edit
</Button>
</Tooltip>
</GridCol>
</Grid>
{/* Content */}
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl">
<Center>
<Text
ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']}
>
{data.name}
</Text>
</Center>
<Divider my="md" color={colors['blue-button']} />
<Box mt="lg">
<Text
py={10}
ta="justify"
fz={{ base: '1rem', md: '1.2rem' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
/>
</Box>
</Box>
</Paper>
</Stack>
</Paper>
);
}
export default PelayananPendudukNonPermanent;

View File

@@ -3,7 +3,7 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Group, Paper, Stack, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -14,6 +14,7 @@ function EditPelayananPerizinanBerusaha() {
const router = useRouter(); const router = useRouter();
const params = useParams() const params = useParams()
const statePerizinanBerusaha = useProxy(stateLayananDesa.pelayananPerizinanBerusaha) const statePerizinanBerusaha = useProxy(stateLayananDesa.pelayananPerizinanBerusaha)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: statePerizinanBerusaha.findById.data?.name || '', name: statePerizinanBerusaha.findById.data?.name || '',
deskripsi: statePerizinanBerusaha.findById.data?.deskripsi || '', deskripsi: statePerizinanBerusaha.findById.data?.deskripsi || '',
@@ -50,64 +51,81 @@ function EditPelayananPerizinanBerusaha() {
} }
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha') router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha')
} }
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Stack gap="xs">
<Box> {/* Header Section */}
<Button <Group mb="md">
variant={'subtle'} <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
onClick={() => router.back()} <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
> <IconArrowBack color={colors['blue-button']} size={24} />
<IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
</Box> </Tooltip>
<Box> <Title order={4} ml="sm" c="dark">
<Paper bg={colors['white-1']} p={'md'} radius={10} w={{ base: '100%', md: '50%' }}> Edit Pelayanan Perizinan Berusaha
<Stack gap={'xs'}> </Title>
</Group>
{/* Form Section */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Perizinan Berusaha</Title> <Title order={3}>Edit Pelayanan Perizinan Berusaha</Title>
<Text fw={"bold"}>Judul</Text>
{/* Nama Field */}
<TextInput <TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name} value={formData.name}
onChange={(val) => { onChange={(e) => setFormData({ ...formData, name: e.target.value })}
setFormData({ required
...formData,
name: val.target.value,
})
}}
/>
<Text fw={"bold"}>Link</Text>
<TextInput
value={formData.link}
onChange={(val) => {
setFormData({
...formData,
link: val.target.value,
})
}}
/>
<Text fw={"bold"}>Deskripsi</Text>
<EditEditor
value={formData.deskripsi}
onChange={(val) => {
setFormData({
...formData,
deskripsi: val,
})
}}
/> />
{/* Link Field */}
<TextInput
label="Link"
placeholder="Masukkan link terkait"
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
/>
{/* Deskripsi Field */}
<Box>
<Title order={6}>Deskripsi</Title>
<EditEditor
value={formData.deskripsi}
onChange={(val) => setFormData({ ...formData, deskripsi: val })}
/>
</Box>
{/* Action Buttons */}
<Group> <Group>
<Button <Button
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit} onClick={handleSubmit}
loading={statePerizinanBerusaha.update.loading} loading={statePerizinanBerusaha.update.loading}
disabled={!formData.name}
> >
Submit {statePerizinanBerusaha.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button
variant="outline"
onClick={() => router.back()}
disabled={statePerizinanBerusaha.update.loading}
>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
</Box>
</Stack> </Stack>
</Box> </Box>
); );

View File

@@ -1,6 +1,23 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Grid, GridCol, Group, Paper, Skeleton, Stack, Stepper, StepperCompleted, StepperStep, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Divider,
Grid,
GridCol,
Group,
Paper,
Skeleton,
Stack,
Stepper,
StepperCompleted,
StepperStep,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -9,54 +26,103 @@ import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
function PerizinanBerusaha() { function PerizinanBerusaha() {
const router = useRouter() const router = useRouter();
const pelayananPerizinanBerusaha = useProxy(stateLayananDesa.pelayananPerizinanBerusaha) const pelayananPerizinanBerusaha = useProxy(
stateLayananDesa.pelayananPerizinanBerusaha
);
const [active, setActive] = useState(1); const [active, setActive] = useState(1);
const nextStep = () => setActive((current) => (current < 6 ? current + 1 : current)); const nextStep = () =>
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); setActive((current) => (current < 6 ? current + 1 : current));
const prevStep = () =>
setActive((current) => (current > 0 ? current - 1 : current));
useShallowEffect(() => { useShallowEffect(() => {
pelayananPerizinanBerusaha.findById.load('1') pelayananPerizinanBerusaha.findById.load('1');
}, []) }, []);
if (!pelayananPerizinanBerusaha.findById.data) { if (!pelayananPerizinanBerusaha.findById.data) {
return ( return (
<Stack> <Stack align="center" justify="center" py="xl">
<Skeleton radius={10} h={800} /> <Skeleton radius="md" height={800} />
</Stack> </Stack>
) );
} }
const data = pelayananPerizinanBerusaha.findById.data;
return ( return (
<Box py={10}> <Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Paper bg={colors['white-1']} p={'md'}> <Stack gap="md">
<Paper bg={colors['BG-trans']} p={'md'}> {/* Header */}
<Box py={15}> <Grid align="center">
<Stack gap={"xs"}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Text fz={"h4"} fw={"bold"}>Preview Pelayanan Perizinan Berusaha</Text> <Title order={3} c={colors['blue-button']}>
Preview Pelayanan Perizinan Berusaha
</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button bg={colors['blue-button']} onClick={() => router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha/edit')}> <Tooltip label="Edit Data Perizinan" withArrow>
<IconEdit size={16} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() =>
router.push(
'/admin/desa/layanan/pelayanan_perizinan_berusaha/edit'
)
}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
</Stack>
</Box>
<Text fz={{ base: "h4", md: 'h2' }} fw={"bold"}>{pelayananPerizinanBerusaha.findById.data.name}</Text>
<Text py={10} ta={"justify"} fz={{ base: "sm", md: 'h3' }} dangerouslySetInnerHTML={{__html: pelayananPerizinanBerusaha.findById.data.deskripsi}} />
<Text py={10} fz={{ base: "sm", md: 'h3' }}>Proses pendaftaran NIB melalui OSS mencakup beberapa langkah umum, seperti:</Text>
<Box p={"xl"} w={{ base: "100%", md: "100%" }} >
<Stepper active={active} onStepClick={setActive} orientation="vertical"
styles={{
separator: {
marginLeft: 25
},
step: { {/* Content */}
padding: '12px 0' <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
} <Box px={{ base: 0, md: 50 }} pb="xl">
}}> <Center>
<Text
ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']}
>
{data.name}
</Text>
</Center>
<Divider my="md" color={colors['blue-button']} />
<Box mt="lg">
<Text
py={10}
ta="justify"
fz={{ base: '1rem', md: '1.2rem' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
/>
<Text
py={10}
fz={{ base: '1rem', md: '1.2rem' }}
fw="bold"
c={colors['blue-button']}
>
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
umum:
</Text>
<Box p="xl" w="100%">
<Stepper
active={active}
onStepClick={setActive}
orientation="vertical"
styles={{
separator: { marginLeft: 25 },
step: { padding: '12px 0' },
}}
>
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun"> <StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
Pendaftaran akun pada portal OSS Pendaftaran akun pada portal OSS
</StepperStep> </StepperStep>
@@ -81,16 +147,37 @@ function PerizinanBerusaha() {
</Stepper> </Stepper>
<Group justify="center" mt="xl"> <Group justify="center" mt="xl">
<Button variant="default" onClick={prevStep}>Back</Button> <Button variant="default" onClick={prevStep}>
Back
</Button>
<Button onClick={nextStep}>Next step</Button> <Button onClick={nextStep}>Next step</Button>
</Group> </Group>
<Text py={35} ta={"justify"} fz={{ base: "sm", md: 'h3' }}>Penting untuk diingat bahwa prosedur dan persyaratan dapat berubah </Box>
seiring waktu. Untuk informasi yang lebih akurat dan terkini, saya sarankan untuk mengunjungi situs
resmi OSS <a href={pelayananPerizinanBerusaha.findById.data.link}>{pelayananPerizinanBerusaha.findById.data.link}</a> atau menghubungi instansi terkait di pemerintah Indonesia yang bertanggung jawab atas urusan perizinan usaha.</Text> <Text
py={35}
ta="justify"
fz={{ base: '1rem', md: '1.2rem' }}
>
Penting untuk diingat bahwa prosedur dan persyaratan dapat
berubah seiring waktu. Untuk informasi yang lebih akurat dan
terkini, silakan kunjungi situs resmi OSS{' '}
<a
href={data.link}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors['blue-button'] }}
>
{data.link}
</a>{' '}
atau hubungi instansi terkait di pemerintah Indonesia yang
bertanggung jawab atas urusan perizinan usaha.
</Text>
</Box>
</Box> </Box>
</Paper> </Paper>
</Stack>
</Paper> </Paper>
</Box>
); );
} }

View File

@@ -4,7 +4,18 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -13,9 +24,10 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function EditSuratKeterangan() { function EditSuratKeterangan() {
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
const stateSurat = useProxy(stateLayananDesa.suratKeterangan) const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImage2, setPreviewImage2] = useState<string | null>(null); const [previewImage2, setPreviewImage2] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -25,39 +37,32 @@ function EditSuratKeterangan() {
deskripsi: stateSurat.edit.form.deskripsi, deskripsi: stateSurat.edit.form.deskripsi,
imageId: stateSurat.edit.form.imageId, imageId: stateSurat.edit.form.imageId,
image2Id: stateSurat.edit.form.image2Id, image2Id: stateSurat.edit.form.image2Id,
}) });
useEffect(() => { useEffect(() => {
const loadSurat = async () => { const loadSurat = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await stateSurat.edit.load(id); const data = await stateSurat.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || "", name: data.name || '',
deskripsi: data.deskripsi || "", deskripsi: data.deskripsi || '',
imageId: data.imageId || "", imageId: data.imageId || '',
image2Id: data.image2Id || "", image2Id: data.image2Id || '',
}); });
if (data.image?.link) { setPreviewImage(data.image?.link || null);
setPreviewImage(data.image.link); setPreviewImage2(data.image2?.link || null);
} else {
setPreviewImage(null);
}
if (data.image2?.link) {
setPreviewImage2(data.image2.link);
} else {
setPreviewImage2(null);
}
} }
} catch (error) { } catch (error) {
console.error("Error loading surat:", error); console.error('Error loading surat:', error);
toast.error("Gagal memuat data surat"); toast.error('Gagal memuat data surat');
} }
}; };
loadSurat(); loadSurat();
}, [params?.id]); }, [params?.id]);
@@ -65,171 +70,199 @@ function EditSuratKeterangan() {
try { try {
stateSurat.edit.form = { stateSurat.edit.form = {
...stateSurat.edit.form, ...stateSurat.edit.form,
name: formData.name, ...formData,
deskripsi: formData.deskripsi, };
imageId: formData.imageId,
image2Id: formData.image2Id,
}
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
stateSurat.edit.form.imageId = uploaded.id; stateSurat.edit.form.imageId = uploaded.id;
} }
if (file2) { if (file2) {
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name }); const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
stateSurat.edit.form.image2Id = uploaded.id; stateSurat.edit.form.image2Id = uploaded.id;
} }
await stateSurat.edit.update() await stateSurat.edit.update();
toast.success("Surat berhasil diperbarui!") toast.success('Surat berhasil diperbarui!');
router.push("/admin/desa/layanan/pelayanan_surat_keterangan") router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
} catch (error) { } catch (error) {
console.error("Error updating surat:", error); console.error('Error updating surat:', error);
toast.error("Terjadi kesalahan saat memperbarui surat"); toast.error('Terjadi kesalahan saat memperbarui surat');
} }
} };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Back Button */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Surat Keterangan
<Title order={3}>Edit Surat Keterangan</Title> </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 <TextInput
label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan"
value={formData.name} value={formData.name}
onChange={(val) => { onChange={(e) => setFormData({ ...formData, name: e.target.value })}
setFormData({ ...formData, name: val.target.value }); required
}}
label={<Text fz={"sm"} fw={"bold"}>Nama Surat Keterangan</Text>}
placeholder="masukkan nama surat keterangan"
/> />
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text> <Text fz="sm" fw="bold" mb={6}>
Konten
</Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(htmlContent) => { onChange={(htmlContent) => setFormData({ ...formData, deskripsi: htmlContent })}
setFormData({ ...formData, deskripsi: htmlContent });
}}
/> />
</Box> </Box>
{/* Upload Gambar 1 */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box > Gambar 1
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const file = files[0]; // Hanya ambil file pertama const selectedFile = files[0];
if (file) { if (selectedFile) {
setFile(file); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(file)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
maxSize={5 * 1024 ** 2} // 5MB onReject={() => toast.error('File tidak valid, gunakan format gambar')}
accept={{ maxSize={5 * 1024 ** 2}
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] accept={{ 'image/*': [] }}
}} radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag images here or click to select files
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Attach as many files as you like, each file should not exceed 5mb Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar 1"
width={280} radius="md"
height={180} style={{
fit="cover" maxHeight: 220,
radius="sm" objectFit: 'contain',
mt="md" border: `1px solid ${colors['blue-button']}`,
}}
/> />
</Box>
)} )}
</Box> </Box>
</Box>
<Box> {/* Upload Gambar 2 */}
<Text fz={"md"} fw={"bold"}>Gambar</Text>
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar 2
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const file = files[0]; // Hanya ambil file pertama const selectedFile = files[0];
if (file) { if (selectedFile) {
setFile2(file); setFile2(selectedFile);
setPreviewImage2(URL.createObjectURL(file)); // Buat preview setPreviewImage2(URL.createObjectURL(selectedFile));
} }
}} }}
maxSize={5 * 1024 ** 2} // 5MB onReject={() => toast.error('File tidak valid, gunakan format gambar')}
accept={{ maxSize={5 * 1024 ** 2}
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] accept={{ 'image/*': [] }}
}} radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag images here or click to select files
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Attach as many files as you like, each file should not exceed 5mb Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{previewImage2 && ( {previewImage2 && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image <Image
src={previewImage2} src={previewImage2}
alt="Preview" alt="Preview Gambar 2"
width={280} radius="md"
height={180} style={{
fit="cover" maxHeight: 220,
radius="sm" objectFit: 'contain',
mt="md" border: `1px solid ${colors['blue-button']}`,
}}
/> />
</Box>
)} )}
</Box> </Box>
</Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -2,100 +2,177 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailSuratKeterangan() { function DetailSuratKeterangan() {
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan) const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
suratKeteranganState.findUnique.load(params?.id as string) suratKeteranganState.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
suratKeteranganState.delete.byId(selectedId) suratKeteranganState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/layanan/pelayanan_surat_keterangan") router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
}
} }
};
if (!suratKeteranganState.findUnique.data) { if (!suratKeteranganState.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
{Array.from({ length: 10 }).map((_, k) => ( <Skeleton height={500} radius="md" />
<Skeleton key={k} h={40} />
))}
</Stack> </Stack>
) );
} }
const data = suratKeteranganState.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Kembali */}
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Surat Keterangan</Text>
{suratKeteranganState.findUnique.data ? (
<Paper key={suratKeteranganState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Nama</Text>
<Text fz={"lg"}>{suratKeteranganState.findUnique.data?.name}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
<Text fz={"lg"}dangerouslySetInnerHTML={{ __html: suratKeteranganState.findUnique.data?.deskripsi }}></Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 150, md: 150, lg: 150 }} src={suratKeteranganState.findUnique.data?.image?.link} alt="gambar" />
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 150, md: 150, lg: 150 }} src={suratKeteranganState.findUnique.data?.image2?.link} alt="gambar" />
</Box>
<Flex gap={"xs"} mt={10}>
<Button <Button
onClick={() => { variant="subtle"
if (suratKeteranganState.findUnique.data) { onClick={() => router.back()}
setSelectedId(suratKeteranganState.findUnique.data.id); leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
setModalHapus(true); mb={15}
}
}}
disabled={suratKeteranganState.delete.loading || !suratKeteranganState.findUnique.data}
color={"red"}
> >
<IconX size={20} /> Kembali
</Button> </Button>
<Button
onClick={() => { <Paper
if (suratKeteranganState.findUnique.data) { withBorder
router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${suratKeteranganState.findUnique.data.id}/edit`); w={{ base: '100%', md: '60%' }}
} bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Surat Keterangan
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">
Nama
</Text>
<Text fz="md" c="dimmed">
{data?.name || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Deskripsi
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: data?.deskripsi || '-',
}} }}
disabled={!suratKeteranganState.findUnique.data} />
color={"green"} </Box>
<Box>
<Text fz="lg" fw="bold">
Gambar
</Text>
{data?.image?.link ? (
<Image
src={data.image.link}
alt="gambar"
w={200}
h={200}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">
Tidak ada gambar
</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">
Gambar 2
</Text>
{data?.image2?.link ? (
<Image
src={data.image2.link}
alt="gambar"
w={200}
h={200}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">
Tidak ada gambar
</Text>
)}
</Box>
<Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
disabled={suratKeteranganState.delete.loading}
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_surat_keterangan/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
@@ -104,7 +181,7 @@ function DetailSuratKeterangan() {
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus berita ini?' text="Apakah Anda yakin ingin menghapus surat keterangan ini?"
/> />
</Box> </Box>
); );

View File

@@ -1,9 +1,21 @@
'use client' 'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -12,25 +24,25 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateSuratKeterangan() { function CreateSuratKeterangan() {
const stateSurat = useProxy(stateLayananDesa.suratKeterangan) const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null); const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null); const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
const router = useRouter() const router = useRouter();
const resetForm = () => { const resetForm = () => {
stateSurat.create.form = { stateSurat.create.form = {
name: "", name: '',
deskripsi: "", deskripsi: '',
imageId: "", imageId: '',
image2Id: "" image2Id: '',
} };
setPreviewImage(null) setPreviewImage(null);
setPreviewImage2(null) setPreviewImage2(null);
} };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!previewImage) { if (!previewImage) {
return toast.warn("Pilih file gambar utama terlebih dahulu"); return toast.warn('Pilih file gambar utama terlebih dahulu');
} }
try { try {
@@ -42,11 +54,10 @@ function CreateSuratKeterangan() {
const uploadedImage1 = res1.data?.data; const uploadedImage1 = res1.data?.data;
if (!uploadedImage1?.id) { if (!uploadedImage1?.id) {
return toast.error("Gagal upload gambar utama"); return toast.error('Gagal upload gambar utama');
} }
let uploadedImage2 = null; let uploadedImage2 = null;
// Upload gambar kedua jika ada
if (previewImage2) { if (previewImage2) {
const res2 = await ApiFetch.api.fileStorage.create.post({ const res2 = await ApiFetch.api.fileStorage.create.post({
file: previewImage2.file, file: previewImage2.file,
@@ -55,44 +66,58 @@ function CreateSuratKeterangan() {
uploadedImage2 = res2.data?.data; uploadedImage2 = res2.data?.data;
} }
// Set form data
stateSurat.create.form.imageId = uploadedImage1.id; stateSurat.create.form.imageId = uploadedImage1.id;
if (uploadedImage2?.id) { if (uploadedImage2?.id) {
stateSurat.create.form.image2Id = uploadedImage2.id; stateSurat.create.form.image2Id = uploadedImage2.id;
} }
// Create the record
await stateSurat.create.create(); await stateSurat.create.create();
// Reset form dan redirect
resetForm(); resetForm();
toast.success("Data surat keterangan berhasil ditambahkan"); toast.success('Data surat keterangan berhasil ditambahkan');
router.push("/admin/desa/layanan/pelayanan_surat_keterangan"); router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
} catch (error) { } catch (error) {
console.error("Error creating surat keterangan:", error); console.error('Error creating surat keterangan:', error);
toast.error("Terjadi kesalahan saat menambahkan surat keterangan"); toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
} }
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Tambah Surat Keterangan
<Title order={3}>Create Surat Keterangan</Title> </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">
{/* Nama Surat */}
<TextInput <TextInput
value={stateSurat.create.form.name} value={stateSurat.create.form.name}
onChange={(val) => { onChange={(val) => (stateSurat.create.form.name = val.target.value)}
stateSurat.create.form.name = val.target.value; label={<Text fz="sm" fw="bold">Nama Surat Keterangan</Text>}
}} placeholder="Masukkan nama surat keterangan"
label={<Text fz={"sm"} fw={"bold"}>Nama Surat Keterangan</Text>} required
placeholder="masukkan nama surat keterangan"
/> />
{/* Konten */}
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text> <Text fz="sm" fw="bold" mb={6}>
Konten
</Text>
<CreateEditor <CreateEditor
value={stateSurat.create.form.deskripsi} value={stateSurat.create.form.deskripsi}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -100,106 +125,124 @@ function CreateSuratKeterangan() {
}} }}
/> />
</Box> </Box>
{/* Gambar Utama */}
<Box> <Box>
<Text fz={"md"} fw={"bold"} mb="sm">Gambar Utama</Text> <Text fw="bold" fz="sm" mb={6}>
Gambar Utama
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const file = files[0]; const file = files[0];
if (file) { if (file) {
setPreviewImage({ setPreviewImage({
file, file,
preview: URL.createObjectURL(file) preview: URL.createObjectURL(file),
}); });
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ accept={{ 'image/*': [] }}
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] radius="md"
}} p="xl"
> >
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={32} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={32} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={32} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="md" inline>Seret gambar ke sini atau klik untuk memilih</Text>
<Text size="sm" c="dimmed" inline mt={7} display="block">
Ukuran maksimal 5MB (JPEG, JPG, PNG, WebP)
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage.preview} src={previewImage.preview}
alt="Preview Gambar Utama" alt="Preview Gambar Utama"
width={280} radius="md"
height={180} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
fit="cover"
radius="sm"
mt="md"
/> />
</Box>
)} )}
</Box> </Box>
<Box mt="lg"> {/* Gambar Tambahan */}
<Text fz={"md"} fw={"bold"} mb="sm">Gambar Tambahan (Opsional)</Text> <Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Tambahan (Opsional)
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const file = files[0]; const file = files[0];
if (file) { if (file) {
setPreviewImage2({ setPreviewImage2({
file, file,
preview: URL.createObjectURL(file) preview: URL.createObjectURL(file),
}); });
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ accept={{ 'image/*': [] }}
'image/*': ['.jpeg', '.jpg', '.png', '.webp'] radius="md"
}} p="xl"
> >
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={32} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={32} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={32} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="md" inline>Seret gambar ke sini atau klik untuk memilih</Text>
<Text size="sm" c="dimmed" inline mt={7} display="block">
Ukuran maksimal 5MB (JPEG, JPG, PNG, WebP)
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{previewImage2 ? ( {previewImage2 ? (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage2.preview} src={previewImage2.preview}
alt="Preview Gambar Tambahan" alt="Preview Gambar Tambahan"
width={280} radius="md"
height={180} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
fit="cover"
radius="sm"
mt="md"
/> />
</Box>
) : ( ) : (
<Text size="sm" c="dimmed" mt="sm"> <Text size="sm" c="dimmed" mt="sm" ta="center">
Kosongkan jika tidak ada gambar tambahan Kosongkan jika tidak ada gambar tambahan
</Text> </Text>
)} )}
</Box> </Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan</Button>
{/* Tombol Simpan */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,13 +1,30 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
function SuratKeterangan() { function SuratKeterangan() {
@@ -16,7 +33,7 @@ function SuratKeterangan() {
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Pelayanan Surat Keterangan' title='Pelayanan Surat Keterangan'
placeholder='pencarian' placeholder='Cari nama atau deskripsi...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,8 +44,8 @@ function SuratKeterangan() {
} }
function ListSuratKeterangan({ search }: { search: string }) { function ListSuratKeterangan({ search }: { search: string }) {
const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan) const suratKeteranganState = useProxy(stateLayananDesa.suratKeterangan);
const router = useRouter() const router = useRouter();
const { const {
data, data,
@@ -39,102 +56,111 @@ function ListSuratKeterangan({ search }: { search: string }) {
} = suratKeteranganState.findMany; } = suratKeteranganState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10) load(page, 10, search);
}, []) }, [page, search]);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!data) return []; if (!data) return [];
return data.filter(item => {
const keyword = search.toLowerCase(); const keyword = search.toLowerCase();
return ( return data.filter(item =>
item.name?.toLowerCase().includes(keyword) || item.name?.toLowerCase().includes(keyword) ||
item.deskripsi?.toLowerCase().includes(keyword) item.deskripsi?.toLowerCase().includes(keyword)
); );
})
}, [data, search]); }, [data, search]);
// Handle loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton height={300} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
if (data.length === 0) {
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<JudulList <Group justify="space-between" mb="md">
title='List Surat Keterangan' <Title order={4}>List Surat Keterangan</Title>
href='/admin/desa/layanan/pelayanan_surat_keterangan/create' <Tooltip label="Tambah Surat Keterangan" withArrow>
/> <Button
<Box style={{ overflowX: "auto" }}> leftSection={<IconPlus size={18} />}
<Table striped withTableBorder withRowBorders> color="blue"
<TableThead> variant="light"
<TableTr> onClick={() =>
<TableTh>Nama</TableTh> router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
<TableTh>Deskripsi</TableTh>
<TableTh>Detail</TableTh>
</TableTr>
</TableThead>
</Table>
</Box>
</Paper>
</Box>
);
} }
return ( >
<Box py={10}> Tambah Baru
<Paper bg={colors['white-1']} p={'md'}> </Button>
<JudulList </Tooltip>
title='List Surat Keterangan' </Group>
href='/admin/desa/layanan/pelayanan_surat_keterangan/create' <Box style={{ overflowX: "auto" }}>
/> <Table highlightOnHover>
<Table striped withTableBorder withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Nama</TableTh> <TableTh style={{ width: '30%' }}>Nama</TableTh>
<TableTh>Deskripsi</TableTh> <TableTh style={{ width: '45%' }}>Deskripsi</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '30%' }}>
<Box w={200}> <Box w={200}>
<Text truncate="end" fz={"sm"}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>
</Box> {item.name}
</TableTd>
<TableTd>
<Box w={300}>
<Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd>
<Text>
<Button onClick={() => router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`)}>
<IconDeviceImac size={20} />
</Button>
</Text> </Text>
</Box>
</TableTd>
<TableTd style={{ width: '45%' }}>
<Box w={200}>
<Text truncate="end" lineClamp={1} fz="sm" c="dimmed"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`)
}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">Tidak ada data surat keterangan yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10); load(newPage, 10, search);
window.scrollTo(0, 0); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -2,22 +2,34 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
function EditPelayananTelunjukSakti() { function EditPelayananTelunjukSakti() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa) const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: stateTelunjukDesa.edit.form.name, name: stateTelunjukDesa.edit.form.name,
deskripsi: stateTelunjukDesa.edit.form.deskripsi, deskripsi: stateTelunjukDesa.edit.form.deskripsi,
link: stateTelunjukDesa.edit.form.link, link: stateTelunjukDesa.edit.form.link,
}) });
useEffect(() => { useEffect(() => {
const loadPelayananTelunjukSakti = async () => { const loadPelayananTelunjukSakti = async () => {
@@ -27,14 +39,14 @@ function EditPelayananTelunjukSakti() {
const data = await stateTelunjukDesa.edit.load(id); const data = await stateTelunjukDesa.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name, name: data.name || '',
deskripsi: data.deskripsi, deskripsi: data.deskripsi || '',
link: data.link, link: data.link || '',
}); });
} }
} catch (error) { } catch (error) {
console.error("Error loading pelayanan telunjuk sakti:", error); console.error('Error loading pelayanan telunjuk sakti:', error);
toast.error("Gagal memuat data pelayanan telunjuk sakti"); toast.error('Gagal memuat data pelayanan telunjuk sakti');
} }
}; };
loadPelayananTelunjukSakti(); loadPelayananTelunjukSakti();
@@ -44,54 +56,83 @@ function EditPelayananTelunjukSakti() {
try { try {
stateTelunjukDesa.edit.form = { stateTelunjukDesa.edit.form = {
...stateTelunjukDesa.edit.form, ...stateTelunjukDesa.edit.form,
name: formData.name, ...formData,
deskripsi: formData.deskripsi, };
link: formData.link, await stateTelunjukDesa.edit.update();
} toast.success('Pelayanan telunjuk sakti berhasil diperbarui!');
await stateTelunjukDesa.edit.update() router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa');
toast.success("Pelayanan telunjuk sakti berhasil diperbarui!")
router.push("/admin/desa/layanan/pelayanan_telunjuk_sakti_desa")
} catch (error) { } catch (error) {
console.error("Error updating pelayanan telunjuk sakti:", error); console.error('Error updating pelayanan telunjuk sakti:', error);
toast.error("Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti"); toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
}
} }
};
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Back Button + Title */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Pelayanan Telunjuk Sakti Desa
<Title order={3}>Edit Surat Keterangan</Title> </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">
{/* Nama */}
<TextInput <TextInput
label="Nama Pelayanan"
placeholder="Masukkan nama pelayanan"
value={formData.name} value={formData.name}
onChange={(val) => { onChange={(e) => setFormData({ ...formData, name: e.target.value })}
setFormData({ ...formData, name: val.target.value }); required
}}
label={<Text fz={"sm"} fw={"bold"}>Nama Surat Keterangan</Text>}
placeholder="masukkan nama surat keterangan"
/> />
<TextInput
{/* Deskripsi pakai editor */}
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi
</Text>
<EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(val) => { onChange={(htmlContent) => setFormData({ ...formData, deskripsi: htmlContent })}
setFormData({ ...formData, deskripsi: val.target.value });
}}
label={<Text fz={"sm"} fw={"bold"}>Tautan Link</Text>}
placeholder="masukkan tautan link"
/> />
</Box>
{/* Link */}
<TextInput <TextInput
label="Link"
placeholder="Masukkan link terkait"
value={formData.link} value={formData.link}
onChange={(val) => { onChange={(e) => setFormData({ ...formData, link: e.target.value })}
setFormData({ ...formData, link: val.target.value });
}}
label={<Text fz={"sm"} fw={"bold"}>Link</Text>}
placeholder="masukkan link"
/> />
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan</Button>
{/* Tombol Simpan */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -2,109 +2,166 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailPelayananTelunjukSakti() { function DetailPelayananTelunjukSakti() {
const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa) const telunjukSaktiState = useProxy(
const [modalHapus, setModalHapus] = useState(false) stateLayananDesa.pelayananTelunjukSaktiDesa
const [selectedId, setSelectedId] = useState<string | null>(null) );
const params = useParams() const [modalHapus, setModalHapus] = useState(false);
const router = useRouter() const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
telunjukSaktiState.findUnique.load(params?.id as string) telunjukSaktiState.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
telunjukSaktiState.delete.byId(selectedId) telunjukSaktiState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/layanan/pelayanan_telunjuk_sakti_desa") router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa');
}
} }
};
if (!telunjukSaktiState.findUnique.data) { if (!telunjukSaktiState.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
{Array.from({ length: 10 }).map((_, k) => ( <Skeleton height={500} radius="md" />
<Skeleton key={k} h={40} />
))}
</Stack> </Stack>
) );
} }
const data = telunjukSaktiState.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Kembali */}
<Button variant="subtle" onClick={() => router.back()}> <Button
<IconArrowBack color={colors['blue-button']} size={25} /> variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button> </Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}> <Paper
<Stack> withBorder
<Text fz={"xl"} fw={"bold"}>Detail Pelayanan Telunjuk Sakti Desa</Text> w={{ base: '100%', md: '60%' }}
{telunjukSaktiState.findUnique.data ? ( bg={colors['white-1']}
<Paper key={telunjukSaktiState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}> p="lg"
<Stack gap={"xs"}> radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Pelayanan Telunjuk Sakti Desa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Nama</Text> <Text fz="lg" fw="bold">
<Text fz={"lg"}>{telunjukSaktiState.findUnique.data?.name}</Text> Nama
</Text>
<Text fz="md" c="dimmed">
{data?.name || '-'}
</Text>
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Link</Text> <Text fz="lg" fw="bold">
Link
</Text>
{data?.link ? (
<Text <Text
fz="md"
component="a" component="a"
href={telunjukSaktiState.findUnique.data?.link} href={data.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
c="blue"
style={{ style={{
display: 'block', display: 'block',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap',
}} }}
> >
{telunjukSaktiState.findUnique.data?.link} {data.link}
</Text> </Text>
) : (
<Text fz="sm" c="dimmed">
Tidak ada link
</Text>
)}
</Box> </Box>
<Box> <Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text> <Text fz="lg" fw="bold">
<Text fz={"lg"}>{telunjukSaktiState.findUnique.data?.deskripsi}</Text> Deskripsi
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: data?.deskripsi || '-',
}}
/>
</Box> </Box>
<Flex gap={"xs"} mt={10}>
<Group gap="sm">
<Tooltip label="Hapus Layanan" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (telunjukSaktiState.findUnique.data) { setSelectedId(data.id);
setSelectedId(telunjukSaktiState.findUnique.data.id);
setModalHapus(true); setModalHapus(true);
}
}} }}
disabled={telunjukSaktiState.delete.loading || !telunjukSaktiState.findUnique.data} variant="light"
color={"red"} radius="md"
size="md"
disabled={telunjukSaktiState.delete.loading}
> >
<IconX size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Layanan" withArrow position="top">
<Button <Button
onClick={() => { color="green"
if (telunjukSaktiState.findUnique.data) { onClick={() =>
router.push(`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${telunjukSaktiState.findUnique.data.id}/edit`); router.push(
`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${data.id}/edit`
)
} }
}} variant="light"
disabled={!telunjukSaktiState.findUnique.data} radius="md"
color={"green"} size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
@@ -113,7 +170,7 @@ function DetailPelayananTelunjukSakti() {
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus berita ini?' text="Apakah Anda yakin ingin menghapus layanan ini?"
/> />
</Box> </Box>
); );

View File

@@ -1,64 +1,117 @@
'use client' 'use client';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { toast } from 'react-toastify';
function CreatePelayananTelunjukDesa() { function CreatePelayananTelunjukDesa() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa) const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter() const router = useRouter();
const resetForm = () => { const resetForm = () => {
stateTelunjukDesa.create.form = { stateTelunjukDesa.create.form = {
name: "", name: '',
deskripsi: "", deskripsi: '',
link: "", link: '',
} };
} };
const handleSubmit = async () => { const handleSubmit = async () => {
await stateTelunjukDesa.create.create() try {
resetForm() await stateTelunjukDesa.create.create();
router.push("/admin/desa/layanan/pelayanan_telunjuk_sakti_desa") resetForm();
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa');
} catch (error) {
console.error('Error create pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat menambahkan data');
} }
};
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Tambah Pelayanan Telunjuk Sakti Desa
<Title order={3}>Create Pelayanan Telunjuk Sakti Desa</Title> </Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Nama */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.name} value={stateTelunjukDesa.create.form.name}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.name = val.target.value; stateTelunjukDesa.create.form.name = val.target.value;
}} }}
label={<Text fz={"sm"} fw={"bold"}>Nama Pelayanan Telunjuk Sakti Desa</Text>} label={<Text fz="sm" fw="bold">Nama Pelayanan</Text>}
placeholder="masukkan nama pelayanan telunjuk sakti desa" placeholder="Masukkan nama pelayanan telunjuk sakti desa"
required
/> />
{/* Deskripsi */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.deskripsi} value={stateTelunjukDesa.create.form.deskripsi}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.deskripsi = val.target.value; stateTelunjukDesa.create.form.deskripsi = val.target.value;
}} }}
label={<Text fz={"sm"} fw={"bold"}>Tautan Link</Text>} label={<Text fz="sm" fw="bold">Deskripsi</Text>}
placeholder="masukkan tautan link" placeholder="Masukkan deskripsi pelayanan"
/> />
{/* Link */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.link} value={stateTelunjukDesa.create.form.link}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.link = val.target.value; stateTelunjukDesa.create.form.link = val.target.value;
}} }}
label={<Text fz={"sm"} fw={"bold"}>Link</Text>} label={<Text fz="sm" fw="bold">Link</Text>}
placeholder="masukkan link" placeholder="Masukkan link pelayanan"
/> />
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan</Button>
{/* Tombol Simpan */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,13 +1,187 @@
// /* eslint-disable react-hooks/exhaustive-deps */
// 'use client'
// import colors from '@/con/colors';
// import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
// import { IconDeviceImac, IconSearch } from '@tabler/icons-react';
// import { useRouter } from 'next/navigation';
// import { useEffect, useMemo, useState } from 'react';
// import { useProxy } from 'valtio/utils';
// import HeaderSearch from '../../../_com/header';
// import JudulList from '../../../_com/judulList';
// import stateLayananDesa from '../../../_state/desa/layananDesa';
// function PelayananTelunjukSakti() {
// const [search, setSearch] = useState("");
// return (
// <Box>
// <HeaderSearch
// title='Posisi Organisasi'
// placeholder='pencarian'
// searchIcon={<IconSearch size={20} />}
// value={search}
// onChange={(e) => setSearch(e.currentTarget.value)}
// />
// <ListPelayananTelunjukSakti search={search} />
// </Box>
// );
// }
// function ListPelayananTelunjukSakti({ search }: { search: string }) {
// const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa)
// const router = useRouter()
// const {
// data,
// page,
// totalPages,
// loading,
// load,
// } = telunjukSaktiState.findMany;
// useEffect(() => {
// load(page, 10)
// }, [])
// const filteredData = useMemo(() => {
// if (!data) return [];
// return data.filter(item => {
// const keyword = search.toLowerCase();
// return (
// item.name?.toLowerCase().includes(keyword) ||
// item.link?.toLowerCase().includes(keyword) ||
// item.deskripsi?.toLowerCase().includes(keyword)
// );
// })
// .sort((a, b) => a.posisi?.hierarki - b.posisi?.hierarki);
// }, [data, search]);
// if (loading || !data) {
// return (
// <Stack py={10}>
// <Skeleton height={300} />
// </Stack>
// );
// }
// if (data.length === 0) {
// return (
// <Box py={10}>
// <Paper bg={colors['white-1']} p={'md'}>
// <JudulList
// title='List Pelayanan Telunjuk Sakti Desa'
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
// />
// <Table striped withTableBorder withRowBorders>
// <TableThead>
// <TableTr>
// <TableTh>Nama</TableTh>
// <TableTh>Link</TableTh>
// <TableTh>Detail</TableTh>
// </TableTr>
// </TableThead>
// <TableTbody>
// <TableTr>
// <TableTd colSpan={3}>
// <Text fz={"sm"} color="gray.5">
// Tidak ada data
// </Text>
// </TableTd>
// </TableTr>
// </TableTbody>
// </Table>
// </Paper>
// </Box>
// );
// }
// return (
// <Box py={10}>
// <Paper bg={colors['white-1']} p={'md'}>
// <JudulList
// title='List Pelayanan Telunjuk Sakti Desa'
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
// />
// <Table striped withTableBorder withRowBorders>
// <TableThead>
// <TableTr>
// <TableTh>Nama</TableTh>
// <TableTh>Link</TableTh>
// <TableTh>Detail</TableTh>
// </TableTr>
// </TableThead>
// <TableTbody>
// {filteredData.map((item) => (
// <TableTr key={item.id}>
// <TableTd>
// <Box w={100}>
// <Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.name }} />
// </Box>
// </TableTd>
// <TableTd>
// <Box w={100}>
// <a href={item.link} target="_blank" rel="noopener noreferrer">
// <Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} truncate="end" fz={"sm"} />
// </a>
// </Box>
// </TableTd>
// <TableTd>
// <Text>
// <Button onClick={() => router.push(`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`)}>
// <IconDeviceImac size={20} />
// </Button>
// </Text>
// </TableTd>
// </TableTr>
// ))}
// </TableTbody>
// </Table>
// </Paper>
// <Center>
// <Pagination
// value={page}
// onChange={(newPage) => {
// load(newPage, 10);
// window.scrollTo(0, 0);
// }}
// total={totalPages}
// mt="md"
// mb="md"
// />
// </Center>
// </Box>
// );
// }
// export default PelayananTelunjukSakti;
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
function PelayananTelunjukSakti() { function PelayananTelunjukSakti() {
@@ -15,8 +189,8 @@ function PelayananTelunjukSakti() {
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Posisi Organisasi' title="Pelayanan Telunjuk Sakti"
placeholder='pencarian' placeholder="Cari layanan..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,125 +201,113 @@ function PelayananTelunjukSakti() {
} }
function ListPelayananTelunjukSakti({ search }: { search: string }) { function ListPelayananTelunjukSakti({ search }: { search: string }) {
const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa) const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter() const router = useRouter();
const { const { data, page, totalPages, loading, load } = telunjukSaktiState.findMany;
data,
page,
totalPages,
loading,
load,
} = telunjukSaktiState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10) load(page, 10, search);
}, []) }, [page, search]);
const filteredData = useMemo(() => { const filteredData = data || [];
if (!data) return [];
return data.filter(item => {
const keyword = search.toLowerCase();
return (
item.name?.toLowerCase().includes(keyword) ||
item.link?.toLowerCase().includes(keyword) ||
item.deskripsi?.toLowerCase().includes(keyword)
);
})
.sort((a, b) => a.posisi?.hierarki - b.posisi?.hierarki);
}, [data, search]);
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton height={300} /> <Skeleton height={400} radius="md" />
</Stack> </Stack>
); );
} }
if (data.length === 0) {
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<JudulList <Group justify="space-between" mb="md">
title='List Pelayanan Telunjuk Sakti Desa' <Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title>
href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create' <Tooltip label="Tambah Layanan" withArrow>
/> <Button
<Table striped withTableBorder withRowBorders> leftSection={<IconPlus size={18} />}
<TableThead> color="blue"
<TableTr> variant="light"
<TableTh>Nama</TableTh> onClick={() =>
<TableTh>Link</TableTh> router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create')
<TableTh>Detail</TableTh>
</TableTr>
</TableThead>
<TableTbody>
<TableTr>
<TableTd colSpan={3}>
<Text fz={"sm"} color="gray.5">
Tidak ada data
</Text>
</TableTd>
</TableTr>
</TableTbody>
</Table>
</Paper>
</Box>
);
} }
>
return ( Tambah Baru
<Box py={10}> </Button>
<Paper bg={colors['white-1']} p={'md'}> </Tooltip>
<JudulList </Group>
title='List Pelayanan Telunjuk Sakti Desa' <Box style={{ overflowX: 'auto' }}>
href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create' <Table highlightOnHover style={{ minWidth: '700px' }}>
/>
<Table striped withTableBorder withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Nama</TableTh> <TableTh style={{ width: '30%' }}>Nama</TableTh>
<TableTh>Link</TableTh> <TableTh style={{ width: '40%' }}>Link</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '30%' }}>Detail</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Box w={200}>
<Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.name }} /> <Text fw={500} truncate="end" lineClamp={1}>
</Box> {item.name}
</Text></Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={100}> <Box w={200}>
<a href={item.link} target="_blank" rel="noopener noreferrer"> <a href={item.link} target="_blank" rel="noopener noreferrer">
<Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} truncate="end" fz={"sm"} /> <Text lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} truncate="end" fz={"sm"} />
</a> </a>
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text> <Button
<Button onClick={() => router.push(`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`)}> variant="light"
<IconDeviceImac size={20} /> color="blue"
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</Text>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data layanan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10); load(newPage, 10, search);
window.scrollTo(0, 0); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>
@@ -153,3 +315,4 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
} }
export default PelayananTelunjukSakti; export default PelayananTelunjukSakti;

View File

@@ -4,7 +4,18 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan'; import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -84,87 +95,104 @@ function EditPenghargaan() {
} }
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Tombol Back + Title */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors["blue-button"]} size={30} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Penghargaan
<Title order={3}>Edit Penghargaan</Title> </Title>
</Group>
{/* Card Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Input Judul */}
<TextInput <TextInput
label="Judul"
placeholder="Masukkan judul penghargaan"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>} required
placeholder="masukkan judul"
/> />
{/* Input Juara */}
<TextInput <TextInput
label="Juara"
placeholder="Masukkan juara"
value={formData.juara} value={formData.juara}
onChange={(e) => setFormData({ ...formData, juara: e.target.value })} onChange={(e) => setFormData({ ...formData, juara: e.target.value })}
label={<Text fz={"sm"} fw={"bold"}>Juara</Text>} required
placeholder="masukkan juara"
/> />
{/* Upload Gambar */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box> Gambar Penghargaan
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag gambar ke sini atau klik untuk pilih file
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Maksimal 5MB dan harus format gambar Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
style={{ radius="md"
maxWidth: '100%', style={{ maxHeight: 220, objectFit: 'contain', border: `1px solid ${colors['blue-button']}` }}
maxHeight: '200px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
/> />
</Box> </Box>
)} )}
</Box>
</Box> </Box>
{/* Deskripsi */}
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Deskripsi</Text> <Text fz="sm" fw="bold" mb={6}>
Deskripsi
</Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -174,7 +202,21 @@ function EditPenghargaan() {
/> />
</Box> </Box>
<Button onClick={handleSubmit}>Edit Penghargaan</Button> {/* Tombol Simpan */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -4,105 +4,166 @@ import penghargaanState from '../../../_state/desa/penghargaan';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { IconArrowBack, IconX, IconEdit } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
function DetailPenghargaan() { function DetailPenghargaan() {
const statePenghargaan = useProxy(penghargaanState) const statePenghargaan = useProxy(penghargaanState);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
useShallowEffect(() => { useShallowEffect(() => {
statePenghargaan.findUnique.load(params?.id as string) statePenghargaan.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
statePenghargaan.delete.byId(selectedId) statePenghargaan.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/penghargaan") router.push('/admin/desa/penghargaan');
}
} }
};
if (!statePenghargaan.findUnique.data) { if (!statePenghargaan.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = statePenghargaan.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Penghargaan</Text>
{statePenghargaan.findUnique.data ? (
<Paper key={statePenghargaan.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 400, md: 400, lg: 400 }} src={statePenghargaan.findUnique.data?.image?.link} alt="gambar" />
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Judul</Text>
<Text fz={"lg"}>{statePenghargaan.findUnique.data?.name}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Juara</Text>
<Text fz={"lg"}>{statePenghargaan.findUnique.data?.juara}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: statePenghargaan.findUnique.data?.deskripsi }} />
</Box>
<Flex gap={"xs"} mt={10}>
<Button <Button
onClick={() => { variant="subtle"
if (statePenghargaan.findUnique.data) { onClick={() => router.back()}
setSelectedId(statePenghargaan.findUnique.data.id); leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
setModalHapus(true); mb={15}
}
}}
disabled={statePenghargaan.delete.loading || !statePenghargaan.findUnique.data}
color={"red"}
> >
<IconX size={20} /> Kembali
</Button> </Button>
<Paper
withBorder
w={{ base: '100%', md: '60%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Penghargaan
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">
Gambar
</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.name || 'Gambar Penghargaan'}
w={200}
h={200}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">
Tidak ada gambar
</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">
Judul
</Text>
<Text fz="md" c="dimmed">
{data.name || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Juara
</Text>
<Text fz="md" c="dimmed">
{data.juara || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Deskripsi
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Group gap="sm" mt={10}>
<Tooltip label="Hapus Penghargaan" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (statePenghargaan.findUnique.data) { setSelectedId(data.id);
router.push(`/admin/desa/penghargaan/${statePenghargaan.findUnique.data.id}/edit`); setModalHapus(true);
}
}} }}
disabled={!statePenghargaan.findUnique.data} variant="light"
color={"green"} radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Penghargaan" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(`/admin/desa/penghargaan/${data.id}/edit`)
}
variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus penghargaan ini?' text="Apakah Anda yakin ingin menghapus penghargaan ini?"
/> />
</Box> </Box>
); );

View File

@@ -1,7 +1,18 @@
'use client' 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -11,74 +22,88 @@ import { useProxy } from 'valtio/utils';
import CreateEditor from '../../../_com/createEditor'; import CreateEditor from '../../../_com/createEditor';
import penghargaanState from '../../../_state/desa/penghargaan'; import penghargaanState from '../../../_state/desa/penghargaan';
function CreatePenghargaan() { function CreatePenghargaan() {
const statePenghargaan = useProxy(penghargaanState) const statePenghargaan = useProxy(penghargaanState);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter() const router = useRouter();
const resetForm = () => { const resetForm = () => {
statePenghargaan.create.form = { statePenghargaan.create.form = {
name: "", name: '',
juara: "", juara: '',
deskripsi: "", deskripsi: '',
imageId: "", imageId: '',
} };
setPreviewImage(null) setPreviewImage(null);
setFile(null) setFile(null);
} };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) { if (!file) {
return toast.error("Silahkan pilih file gambar terlebih dahulu") return toast.warn('Silakan pilih file gambar terlebih dahulu');
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: file, file,
name: file.name name: file.name,
}) });
const uploaded = res.data?.data;
const uploaded = res.data?.data
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar") return toast.error('Gagal mengunggah gambar, silakan coba lagi');
} }
statePenghargaan.create.form.imageId = uploaded.id statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create() await statePenghargaan.create.create();
resetForm() resetForm();
router.push("/admin/desa/penghargaan") router.push('/admin/desa/penghargaan');
};
}
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Tambah Penghargaan
<Title order={3}>Create Penghargaan</Title> </Title>
</Group>
{/* Form */}
<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 <TextInput
value={statePenghargaan.create.form.name} value={statePenghargaan.create.form.name}
onChange={(val) => { onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
statePenghargaan.create.form.name = val.target.value; label={<Text fz="sm" fw="bold">Nama Penghargaan</Text>}
}} placeholder="Masukkan nama penghargaan"
label={<Text fz={"sm"} fw={"bold"}>Nama Penghargaan</Text>} required
placeholder="masukkan nama penghargaan"
/> />
<TextInput <TextInput
value={statePenghargaan.create.form.juara} value={statePenghargaan.create.form.juara}
onChange={(val) => { onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
statePenghargaan.create.form.juara = val.target.value; label={<Text fz="sm" fw="bold">Juara</Text>}
}} placeholder="Masukkan juara"
label={<Text fz={"sm"} fw={"bold"}>Juara</Text>} required
placeholder="masukkan juara"
/> />
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Deskripsi</Text> <Text fz="sm" fw="bold" mb={6}>Deskripsi</Text>
<CreateEditor <CreateEditor
value={statePenghargaan.create.form.deskripsi} value={statePenghargaan.create.form.deskripsi}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -86,63 +111,67 @@ function CreatePenghargaan() {
}} }}
/> />
</Box> </Box>
{/* Dropzone Upload */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fz="sm" fw="bold" mb={6}>Gambar</Text>
<Box>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
style={{ radius="md"
maxWidth: '100%', style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
maxHeight: '200px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
/> />
</Box> </Box>
)} )}
</Box>
</Box> {/* Button Submit */}
</Box> <Group justify="right">
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan</Button> <Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -2,21 +2,38 @@
'use client' 'use client'
import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan'; import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import JudulList from '../../_com/judulList';
function Penghargaan() { function Penghargaan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Penghargaan' title="Penghargaan"
placeholder='pencarian' placeholder="Cari nama atau deskripsi..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,125 +44,114 @@ function Penghargaan() {
} }
function ListPenghargaan({ search }: { search: string }) { function ListPenghargaan({ search }: { search: string }) {
const state = useProxy(penghargaanState) const state = useProxy(penghargaanState);
const router = useRouter() const router = useRouter();
const { const { data, page, totalPages, loading, load } = state.findMany;
data,
page,
totalPages,
loading,
load,
} = state.findMany;
useEffect(() => { useEffect(() => {
load(page, 10) load(page, 10, search);
}, []) }, [page, search]);
const filteredData = useMemo(() => { const filteredData = data || []
if (!data) return [];
return data.filter(item => {
const keyword = search.toLowerCase();
return (
item.name?.toLowerCase().includes(keyword) ||
item.deskripsi?.toLowerCase().includes(keyword)
);
})
}, [data, search]);
// Handle loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton height={300} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
if (data.length === 0) {
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<JudulList <Group justify="space-between" mb="md">
title='List Penghargaan' <Title order={4}>List Penghargaan</Title>
href='/admin/desa/penghargaan/create' <Tooltip label="Tambah Penghargaan" withArrow>
/> <Button
<Table striped withTableBorder withRowBorders> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/penghargaan/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Nama</TableTh> <TableTh style={{ width: '35%' }}>Nama</TableTh>
<TableTh>Deskripsi</TableTh> <TableTh style={{ width: '35%' }}>Deskripsi</TableTh>
<TableTh>Image</TableTh> <TableTh style={{ width: '30%' }}>Aksi</TableTh>
<TableTh>Detail</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
<TableTr> {filteredData.length > 0 ? (
<TableTd colSpan={4}> filteredData.map((item) => (
<Text ta="center">Tidak ada data</Text>
</TableTd>
</TableTr>
</TableTbody>
</Table>
</Paper>
</Box>
)
}
return (
<Box py={10}>
<Paper bg={colors['white-1']} p={'md'}>
<JudulList
title='List Penghargaan'
href='/admin/desa/penghargaan/create'
/>
<Table striped withTableBorder withRowBorders>
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Image</TableTh>
<TableTh>Detail</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Box w={200}>
<Text lineClamp={1} truncate="end" fz={"sm"}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>
</Box> {item.name}
</TableTd>
<TableTd>
<Box w={100}>
<Text lineClamp={1} truncate="end" fz={"sm"} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd>
<Image w={100} src={item.image?.link} alt="gambar" />
</TableTd>
<TableTd>
<Text>
<Button onClick={() => router.push(`/admin/desa/penghargaan/${item.id}`)}>
<IconDeviceImac size={20} />
</Button>
</Text> </Text>
</Box>
</TableTd>
<TableTd>
<Box w={200}>
<Text
truncate="end"
lineClamp={1}
fz="sm"
c="dimmed"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/penghargaan/${item.id}`)
}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data penghargaan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10); load(newPage, 10, search);
window.scrollTo(0, 0); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -1,9 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconListDetails, IconCategory } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) { function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -12,16 +13,21 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
{ {
label: "List Pengumuman", label: "List Pengumuman",
value: "listpengumuman", value: "listpengumuman",
href: "/admin/desa/pengumuman/list-pengumuman" href: "/admin/desa/pengumuman/list-pengumuman",
icon: <IconListDetails size={18} stroke={1.8} />,
tooltip: "Lihat semua daftar pengumuman"
}, },
{ {
label: "Kategori Pengumuman", label: "Kategori Pengumuman",
value: "kategoripengumuman", value: "kategoripengumuman",
href: "/admin/desa/pengumuman/kategori-pengumuman" href: "/admin/desa/pengumuman/kategori-pengumuman",
icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori pengumuman"
}, },
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value)
@@ -39,22 +45,57 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}, [pathname]) }, [pathname])
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Pengumuman</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Pengumuman</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant='pills'
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -1,8 +1,18 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman'; import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Tooltip,
Text,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -10,9 +20,10 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function EditKategoriPengumuman() { function EditKategoriPengumuman() {
const editState = useProxy(stateDesaPengumuman.category) const editState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: editState.update.form.name || '', name: editState.update.form.name || '',
}); });
@@ -23,15 +34,15 @@ function EditKategoriPengumuman() {
if (!id) return; if (!id) return;
try { try {
const data = await editState.update.load(id); // akses langsung, bukan dari proxy const data = await editState.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name || '',
}); });
} }
} catch (error) { } catch (error) {
console.error("Error loading kategori Pengumuman:", error); console.error('Error loading kategori Pengumuman:', error);
toast.error("Gagal memuat data kategori Pengumuman"); toast.error('Gagal memuat data kategori Pengumuman');
} }
}; };
@@ -44,6 +55,7 @@ function EditKategoriPengumuman() {
...editState.update.form, ...editState.update.form,
name: formData.name, name: formData.name,
}; };
await editState.update.update(); await editState.update.update();
toast.success('Kategori Pengumuman berhasil diperbarui!'); toast.success('Kategori Pengumuman berhasil diperbarui!');
router.push('/admin/desa/pengumuman/kategori-pengumuman'); router.push('/admin/desa/pengumuman/kategori-pengumuman');
@@ -54,23 +66,62 @@ function EditKategoriPengumuman() {
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors["blue-button"]} size={30} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Kategori Pengumuman
<Title order={3}>Edit Kategori Pengumuman</Title> </Title>
</Group>
{/* Form */}
<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 <TextInput
label={
<Text fz="sm" fw="bold">
Nama Kategori Pengumuman
</Text>
}
placeholder="Masukkan nama kategori Pengumuman"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) =>
label={<Text fz={"sm"} fw={"bold"}>Nama Kategori Pengumuman</Text>} setFormData({ ...formData, name: e.target.value })
placeholder="masukkan nama kategori Pengumuman" }
required
/> />
<Button onClick={handleSubmit}>Simpan</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,50 +1,87 @@
'use client' 'use client'
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman'; import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateKategoriPengumuman() { function CreateKategoriPengumuman() {
const createState = useProxy(stateDesaPengumuman.category) const createState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
name: "", name: '',
}; };
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create(); await createState.create.create();
resetForm(); resetForm();
router.push("/admin/desa/pengumuman/kategori-pengumuman") router.push('/admin/desa/pengumuman/kategori-pengumuman');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header dengan back button */}
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Pengumuman
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> {/* Form utama */}
<Stack gap={"xs"}> <Paper
<Title order={4}>Create Kategori Pengumuman</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Nama Kategori Pengumuman</Text>} label={<Text fw="bold" fz="sm">Nama Kategori Pengumuman</Text>}
placeholder='Masukkan nama kategori Pengumuman' placeholder="Masukkan nama kategori pengumuman"
value={createState.create.form.name} value={createState.create.form.name || ''}
onChange={(val) => { onChange={(e) => (createState.create.form.name = e.target.value)}
createState.create.form.name = val.target.value; required
}}
/> />
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,25 +1,26 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconEdit, IconSearch, IconTrash } from '@tabler/icons-react'; Box, Button, Center, Paper, Skeleton, Stack,
Table, TableTbody, TableTd, TableTh, TableThead, TableTr,
Text, Title, Tooltip, Pagination
} from '@mantine/core';
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '../../../_state/desa/pengumuman'; import stateDesaPengumuman from '../../../_state/desa/pengumuman';
function KategoriPengumuman() { function KategoriPengumuman() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Kategori Pengumuman' title='Kategori Pengumuman'
placeholder='pencarian' placeholder='Cari nama kategori...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -34,69 +35,84 @@ function ListKategoriPengumuman({ search }: { search: string }) {
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const { data, page, totalPages, loading, load } = listDataState.findMany;
useEffect(() => { useEffect(() => {
listDataState.findMany.load() load(1, 10, search)
}, []) }, [search])
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
listDataState.delete.delete(selectedId) listDataState.delete.delete(selectedId)
setModalHapus(false) setModalHapus(false)
setSelectedId(null) setSelectedId(null)
load(page, 10, search)
listDataState.findMany.load()
} }
} }
const filteredData = (listDataState.findMany.data || []).filter(item => { const filteredData = data || []
const keyword = search.toLowerCase();
return (
item.name.toLowerCase().includes(keyword)
);
});
if (!listDataState.findMany.data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Stack>
<JudulList <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
title='List Kategori Pengumuman' <Title order={4}>List Kategori Pengumuman</Title>
href='/admin/desa/pengumuman/kategori-pengumuman/create' <Tooltip label="Tambah Kategori Pengumuman" withArrow>
/> <Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Box>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover striped withRowBorders style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>No</TableTh> <TableTh style={{ width: '10%' }}>No</TableTh>
<TableTh>Nama</TableTh> <TableTh style={{ width: '60%' }}>Nama</TableTh>
<TableTh>Edit</TableTh> <TableTh style={{ width: '15%' }}>Edit</TableTh>
<TableTh>Hapus</TableTh> <TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item, index) => ( {filteredData.length > 0 ? (
filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fz="sm">{(page - 1) * 10 + index + 1}</Text>
<Text truncate="end" fz={"sm"}>{index + 1}</Text>
</Box>
</TableTd> </TableTd>
<TableTd>{item.name}</TableTd>
<TableTd> <TableTd>
<Button color='green' onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}> <Text truncate lineClamp={1}>{item.name}</Text>
</TableTd>
<TableTd>
<Tooltip label="Edit Kategori Pengumuman" withArrow>
<Button
variant='light'
color='green'
onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}
>
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Hapus Kategori Pengumuman" withArrow>
<Button <Button
variant='light'
color='red' color='red'
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
@@ -105,16 +121,35 @@ function ListKategoriPengumuman({ search }: { search: string }) {
}}> }}>
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada data kategori pengumuman yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */} <Center mt="md">
<Pagination
value={page}
onChange={(newPage) => load(newPage, 10, search)}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}

View File

@@ -7,12 +7,14 @@ import colors from "@/con/colors";
import { import {
Box, Box,
Button, Button,
Group,
Paper, Paper,
Select, Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconArrowBack } from "@tabler/icons-react"; import { IconArrowBack } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
@@ -20,34 +22,34 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
function EditPengumuman() { function EditPengumuman() {
const editState = useProxy(stateDesaPengumuman); const editState = useProxy(stateDesaPengumuman);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: editState.pengumuman.edit.form.judul || '', judul: editState.pengumuman.edit.form.judul || "",
deskripsi: editState.pengumuman.edit.form.deskripsi || '', deskripsi: editState.pengumuman.edit.form.deskripsi || "",
categoryPengumumanId: editState.pengumuman.edit.form.categoryPengumumanId || '', categoryPengumumanId:
content: editState.pengumuman.edit.form.content || '' editState.pengumuman.edit.form.categoryPengumumanId || "",
content: editState.pengumuman.edit.form.content || "",
}); });
// Load pengumuman by id saat pertama kali // Load pengumuman by id saat pertama kali
useEffect(() => { useEffect(() => {
editState.category.findMany.load() editState.category.findMany.load();
const loadpengumuman = async () => { const loadpengumuman = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await stateDesaPengumuman.pengumuman.edit.load(id); // akses langsung, bukan dari proxy const data = await stateDesaPengumuman.pengumuman.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
judul: data.judul || '', judul: data.judul || "",
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || "",
categoryPengumumanId: data.categoryPengumumanId || '', categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || '', content: data.content || "",
}); });
} }
} catch (error) { } catch (error) {
@@ -57,21 +59,18 @@ function EditPengumuman() {
}; };
loadpengumuman(); loadpengumuman();
}, [params?.id]); // ✅ hapus editState dari dependency }, [params?.id]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
// edit global state with form data // update global state
editState.pengumuman.edit.form = { editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form, ...editState.pengumuman.edit.form,
judul: formData.judul, ...formData,
deskripsi: formData.deskripsi,
content: formData.content,
categoryPengumumanId: formData.categoryPengumumanId || ''
}; };
await editState.pengumuman.edit.update(); await editState.pengumuman.edit.update();
toast.success("pengumuman berhasil diperbarui!"); toast.success("Pengumuman berhasil diperbarui!");
router.push("/admin/desa/pengumuman/list-pengumuman"); router.push("/admin/desa/pengumuman/list-pengumuman");
} catch (error) { } catch (error) {
console.error("Error updating pengumuman:", error); console.error("Error updating pengumuman:", error);
@@ -80,57 +79,97 @@ function EditPengumuman() {
}; };
return ( return (
<Box> <Box px={{ base: "sm", md: "lg" }} py="md">
<Box mb={10}> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors["blue-button"]} size={30} /> <Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Pengumuman
<Title order={3}>Edit pengumuman</Title> </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 <TextInput
label="Judul Pengumuman"
placeholder="Masukkan judul"
value={formData.judul} value={formData.judul}
onChange={(e) => setFormData({ ...formData, judul: e.target.value })} onChange={(e) => setFormData({ ...formData, judul: e.target.value })}
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>} required
placeholder="masukkan judul"
/> />
<TextInput <TextInput
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi"
value={formData.deskripsi} value={formData.deskripsi}
onChange={(e) => setFormData({ ...formData, deskripsi: e.target.value })} onChange={(e) =>
label={<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>} setFormData({ ...formData, deskripsi: e.target.value })
placeholder="masukkan deskripsi" }
required
/> />
<Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text>
<EditEditor
value={formData.content}
onChange={(htmlContent) => {
setFormData((prev) => ({ ...prev, content: htmlContent }));
editState.pengumuman.edit.form.content = htmlContent;
}}
/>
</Box>
<Select <Select
value={formData.categoryPengumumanId} value={formData.categoryPengumumanId}
onChange={(val) => setFormData({ ...formData, categoryPengumumanId: val || "" })} onChange={(val) =>
label={<Text fw={"bold"} fz={"sm"}>Kategori</Text>} setFormData({ ...formData, categoryPengumumanId: val || "" })
placeholder='Pilih kategori' }
label="Kategori"
placeholder="Pilih kategori"
data={ data={
editState.category.findMany.data?.map((v) => ({ editState.category.findMany.data?.map((v) => ({
value: v.id, value: v.id,
label: v.name label: v.name,
})) || [] })) || []
} }
clearable clearable
searchable searchable
required required
error={!formData.categoryPengumumanId ? "Pilih kategori" : undefined} error={
!formData.categoryPengumumanId ? "Pilih kategori" : undefined
}
/> />
<Button onClick={handleSubmit}>Edit pengumuman</Button> <Box>
<Text fz="sm" fw="bold" mb={6}>
Konten Lengkap
</Text>
<EditEditor
value={formData.content}
onChange={(htmlContent) =>
setFormData({ ...formData, content: htmlContent })
}
/>
</Box>
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,116 +1,163 @@
'use client' 'use client'
import { Box, Button, Flex, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { useProxy } from 'valtio/utils'; import {
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman'; Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
export default function DetailPengumuman() {
function DetailPengumuman() { const pengumumanState = useProxy(stateDesaPengumuman);
const pengumumanState = useProxy(stateDesaPengumuman) const [modalHapus, setModalHapus] = useState(false);
const [modalHapus, setModalHapus] = useState(false) const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null) const params = useParams();
const params = useParams() const router = useRouter();
const router = useRouter()
useShallowEffect(() => { useShallowEffect(() => {
pengumumanState.pengumuman.findUnique.load(params?.id as string) pengumumanState.pengumuman.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
pengumumanState.pengumuman.delete.byId(selectedId) pengumumanState.pengumuman.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/pengumuman/list-pengumuman") router.push('/admin/desa/pengumuman/list-pengumuman');
}
} }
};
if (!pengumumanState.pengumuman.findUnique.data) { if (!pengumumanState.pengumuman.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={400} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = pengumumanState.pengumuman.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Pengumuman</Text>
{pengumumanState.pengumuman.findUnique.data ? (
<Paper key={pengumumanState.pengumuman.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Kategori</Text>
<Text fz={"lg"}>{pengumumanState.pengumuman.findUnique.data?.CategoryPengumuman?.name}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Judul</Text>
<Text fz={"lg"}>{pengumumanState.pengumuman.findUnique.data?.judul}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
<Text fz={"lg"}>{pengumumanState.pengumuman.findUnique.data?.deskripsi}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Konten</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: pengumumanState.pengumuman.findUnique.data?.content }} />
</Box>
<Flex gap={"xs"} mt={10}>
<Button <Button
onClick={() => { variant="subtle"
if (pengumumanState.pengumuman.findUnique.data) { onClick={() => router.back()}
setSelectedId(pengumumanState.pengumuman.findUnique.data.id); leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
setModalHapus(true); mb={15}
}
}}
disabled={pengumumanState.pengumuman.delete.loading || !pengumumanState.pengumuman.findUnique.data}
color={"red"}
> >
<IconX size={20} /> Kembali
</Button> </Button>
<Button
onClick={() => { <Paper
if (pengumumanState.pengumuman.findUnique.data) { withBorder
router.push(`/admin/desa/pengumuman/list-pengumuman/${pengumumanState.pengumuman.findUnique.data.id}/edit`); w={{ base: '100%', md: '60%' }}
} bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Pengumuman
</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?.CategoryPengumuman?.name || '-'}
</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">
Deskripsi
</Text>
<Text fz="md" c="dimmed">
{data?.deskripsi || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Konten
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: data?.content || '-',
}} }}
disabled={!pengumumanState.pengumuman.findUnique.data} />
color={"green"} </Box>
<Group gap="sm">
<Tooltip label="Hapus Pengumuman" withArrow position="top">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Pengumuman" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(
`/admin/desa/pengumuman/list-pengumuman/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus pengumuman ini?' text="Apakah anda yakin ingin menghapus pengumuman ini?"
/> />
</Box> </Box>
); );
} }
export default DetailPengumuman;

View File

@@ -1,79 +1,110 @@
'use client' 'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman'; import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePengumuman() { function CreatePengumuman() {
const pengumumanState = useProxy(stateDesaPengumuman) const pengumumanState = useProxy(stateDesaPengumuman);
const router = useRouter(); const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
pengumumanState.category.findMany.load() pengumumanState.category.findMany.load();
}, []) }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
await pengumumanState.pengumuman.create.create() await pengumumanState.pengumuman.create.create();
resetForm() resetForm();
router.push("/admin/desa/pengumuman/list-pengumuman") router.push('/admin/desa/pengumuman/list-pengumuman');
} };
const resetForm = () => { const resetForm = () => {
pengumumanState.pengumuman.create.form = { pengumumanState.pengumuman.create.form = {
judul: "", judul: '',
deskripsi: "", deskripsi: '',
content: "", content: '',
categoryPengumumanId: "", categoryPengumumanId: '',
}; };
}; };
return (
<Box>
<Box mb={10}>
<Button onClick={() => router.back()} variant='subtle' color={'blue'}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> return (
<Stack gap={"xs"}> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Title order={4}>Create Pengumuman</Title> {/* Header */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Pengumuman
</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">
{/* Judul */}
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Judul</Text>} value={pengumumanState.pengumuman.create.form.judul}
placeholder='Masukkan judul' onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
onChange={(val) => { label={<Text fz="sm" fw="bold">Judul</Text>}
pengumumanState.pengumuman.create.form.judul = val.target.value placeholder="Masukkan judul pengumuman"
}} required
/> />
{/* Kategori */}
<Select <Select
label={<Text fw={"bold"} fz={"sm"}>Kategori</Text>} label={<Text fz="sm" fw="bold">Kategori</Text>}
placeholder='Pilih kategori' placeholder="Pilih kategori"
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || ""}
onChange={(val) => {
pengumumanState.pengumuman.create.form.categoryPengumumanId = val ?? "";
}}
data={pengumumanState.category.findMany.data?.map((item) => ({ data={pengumumanState.category.findMany.data?.map((item) => ({
label: item.name, label: item.name,
value: item.id, value: item.id,
}))} }))}
onChange={(val) => {
const selected = pengumumanState.category.findMany.data?.find((item) => item.id === val);
if (selected) {
pengumumanState.pengumuman.create.form.categoryPengumumanId = selected.id;
}
}}
searchable searchable
nothingFoundMessage="Tidak ditemukan" nothingFoundMessage="Tidak ditemukan"
/> />
{/* Deskripsi Singkat */}
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Deskripsi Singkat</Text>} value={pengumumanState.pengumuman.create.form.deskripsi}
placeholder='Masukkan deskripsi singkat' onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
onChange={(val) => { label={<Text fz="sm" fw="bold">Deskripsi Singkat</Text>}
pengumumanState.pengumuman.create.form.deskripsi = val.target.value placeholder="Masukkan deskripsi singkat"
}} required
/> />
{/* Konten Editor */}
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Deskripsi</Text> <Text fz="sm" fw="bold" mb={6}>
Konten Lengkap
</Text>
<CreateEditor <CreateEditor
value={pengumumanState.pengumuman.create.form.content} value={pengumumanState.pengumuman.create.form.content}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -82,8 +113,20 @@ function CreatePengumuman() {
/> />
</Box> </Box>
<Group> {/* Tombol Submit */}
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,6 +1,25 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Grid, GridCol, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -9,14 +28,13 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import stateDesaPengumuman from '../../../_state/desa/pengumuman'; import stateDesaPengumuman from '../../../_state/desa/pengumuman';
function Pengumuman() { function Pengumuman() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='List Pengumuman' title="Pengumuman Desa"
placeholder='pencarian' placeholder="Cari judul atau kategori..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,86 +45,107 @@ function Pengumuman() {
} }
function ListPengumuman({ search }: { search: string }) { function ListPengumuman({ search }: { search: string }) {
const pengumumanState = useProxy(stateDesaPengumuman) const pengumumanState = useProxy(stateDesaPengumuman);
const router = useRouter() const router = useRouter();
const {
data, const { data, page, totalPages, loading, load } = pengumumanState.pengumuman.findMany;
page,
totalPages,
loading,
load,
} = pengumumanState.pengumuman.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, search);
}, [page, search]) }, [page, search]);
const filteredData = (data || []) const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Group justify="space-between" mb="md">
<Grid> <Title order={4}>Daftar Pengumuman</Title>
<GridCol span={{ base: 12, md: 11 }}> <Tooltip label="Tambah Pengumuman" withArrow>
<Text fz={"xl"} fw={"bold"}>List Pengumuman</Text> <Button
</GridCol> leftSection={<IconCircleDashedPlus size={18} />}
<GridCol span={{ base: 12, md: 1 }}> color="blue"
<Button onClick={() => router.push("/admin/desa/pengumuman/list-pengumuman/create")} bg={colors['blue-button']}> variant="light"
<IconCircleDashedPlus size={25} /> onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')}
>
Tambah Baru
</Button> </Button>
</GridCol> </Tooltip>
</Grid> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: 'auto' }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w={250}>Judul</TableTh> <TableTh style={{ width: '40%' }}>Judul</TableTh>
<TableTh w={250}>Kategori</TableTh> <TableTh style={{ width: '30%' }}>Kategori</TableTh>
<TableTh w={200}>Detail</TableTh> <TableTh style={{ width: '20%' }}>Detail</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fw={500} truncate="end" lineClamp={1}>
<Text truncate="end" fz={"sm"}>{item.judul}</Text> {item.judul}
</Box> </Text>
</TableTd> </TableTd>
<TableTd >{item.CategoryPengumuman?.name}</TableTd>
<TableTd> <TableTd>
<Button bg={"green"} onClick={() => router.push(`/admin/desa/pengumuman/list-pengumuman/${item.id}`)}> <Text fz="sm" c="dimmed">
<IconDeviceImacCog size={25} /> {item.CategoryPengumuman?.name || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/pengumuman/list-pengumuman/${item.id}`)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">Tidak ada pengumuman yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default Pengumuman; export default Pengumuman;

View File

@@ -1,7 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconCategory, IconListCheck } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -12,17 +13,21 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
{ {
label: "List Potensi", label: "List Potensi",
value: "list_potensi", value: "list_potensi",
href: "/admin/desa/potensi/list-potensi" href: "/admin/desa/potensi/list-potensi",
icon: <IconListCheck size={18} stroke={1.8} />,
tooltip: "Lihat semua potensi desa"
}, },
{ {
label: "Kategori Potensi", label: "Kategori Potensi",
value: "kategori_potensi", value: "kategori_potensi",
href: "/admin/desa/potensi/kategori-potensi" href: "/admin/desa/potensi/kategori-potensi",
icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori potensi"
}, },
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value)
@@ -40,22 +45,57 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
}, [pathname]) }, [pathname])
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Potensi</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Potensi</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant='pills'
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -1,8 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi'; import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -10,9 +19,10 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function EditKategoriPotensi() { function EditKategoriPotensi() {
const editState = useProxy(potensiDesaState.kategoriPotensi) const editState = useProxy(potensiDesaState.kategoriPotensi);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nama: editState.update.form.nama || '', nama: editState.update.form.nama || '',
}); });
@@ -23,15 +33,15 @@ function EditKategoriPotensi() {
if (!id) return; if (!id) return;
try { try {
const data = await editState.update.load(id); // akses langsung, bukan dari proxy const data = await editState.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
nama: data.nama || '', nama: data.nama || '',
}); });
} }
} catch (error) { } catch (error) {
console.error("Error loading kategori potensi:", error); console.error('Error loading kategori potensi:', error);
toast.error("Gagal memuat data kategori potensi"); toast.error('Gagal memuat data kategori potensi');
} }
}; };
@@ -44,6 +54,7 @@ function EditKategoriPotensi() {
...editState.update.form, ...editState.update.form,
nama: formData.nama, nama: formData.nama,
}; };
await editState.update.update(); await editState.update.update();
toast.success('Kategori Potensi berhasil diperbarui!'); toast.success('Kategori Potensi berhasil diperbarui!');
router.push('/admin/desa/potensi/kategori-potensi'); router.push('/admin/desa/potensi/kategori-potensi');
@@ -54,23 +65,49 @@ function EditKategoriPotensi() {
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors["blue-button"]} size={30} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Kategori Potensi
<Title order={3}>Edit Kategori Potensi</Title> </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 <TextInput
label="Nama Kategori Potensi"
placeholder="Masukkan nama kategori potensi"
value={formData.nama} value={formData.nama}
onChange={(e) => setFormData({ ...formData, nama: e.target.value })} onChange={(e) => setFormData({ ...formData, nama: e.target.value })}
label={<Text fz={"sm"} fw={"bold"}>Nama Kategori Potensi</Text>} required
placeholder="masukkan nama kategori potensi"
/> />
<Button onClick={handleSubmit}>Simpan</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,50 +1,87 @@
'use client' 'use client';
import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi'; import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateKategoriPotensi() { function CreateKategoriPotensi() {
const createState = useProxy(potensiDesaState.kategoriPotensi) const createState = useProxy(potensiDesaState.kategoriPotensi);
const router = useRouter(); const router = useRouter();
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
nama: "", nama: '',
}; };
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create(); await createState.create.create();
resetForm(); resetForm();
router.push("/admin/desa/potensi/kategori-potensi") router.push('/admin/desa/potensi/kategori-potensi');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header dengan back button */}
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Potensi
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> {/* Form utama */}
<Stack gap={"xs"}> <Paper
<Title order={4}>Create Kategori Potensi</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Nama Kategori Potensi</Text>} label={<Text fw="bold" fz="sm">Nama Kategori Potensi</Text>}
placeholder='Masukkan nama kategori Potensi' placeholder="Masukkan nama kategori potensi"
value={createState.create.form.nama} value={createState.create.form.nama || ''}
onChange={(val) => { onChange={(e) => (createState.create.form.nama = e.target.value)}
createState.create.form.nama = val.target.value; required
}}
/> />
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,17 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip, Pagination } from '@mantine/core';
import { IconEdit, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import potensiDesaState from '../../../_state/desa/potensi'; import potensiDesaState from '../../../_state/desa/potensi';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
function KategoriPotensi() { function KategoriPotensi() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -19,7 +16,7 @@ function KategoriPotensi() {
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Kategori Potensi' title='Kategori Potensi'
placeholder='pencarian' placeholder='Cari nama kategori...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -34,69 +31,77 @@ function ListKategoriPotensi({ search }: { search: string }) {
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const { data, page, totalPages, loading, load } = listDataState.findMany;
useEffect(() => { useEffect(() => {
listDataState.findMany.load() load(1, 10, search)
}, []) }, [search])
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
listDataState.delete.delete(selectedId) listDataState.delete.delete(selectedId)
setModalHapus(false) setModalHapus(false)
setSelectedId(null) setSelectedId(null)
load(page, 10, search)
listDataState.findMany.load()
} }
} }
const filteredData = (listDataState.findMany.data || []).filter(item => { const filteredData = data || []
const keyword = search.toLowerCase();
return (
item.nama.toLowerCase().includes(keyword)
);
});
if (!listDataState.findMany.data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Stack>
<JudulList <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
title='List Kategori Potensi' <Title order={4}>List Kategori Potensi</Title>
href='/admin/desa/potensi/kategori-potensi/create' <Tooltip label="Tambah Kategori Potensi" withArrow>
/> <Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/potensi/kategori-potensi/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Box>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover striped withRowBorders style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>No</TableTh> <TableTh style={{ width: '10%' }}>No</TableTh>
<TableTh>Nama</TableTh> <TableTh style={{ width: '60%' }}>Nama</TableTh>
<TableTh>Edit</TableTh> <TableTh style={{ width: '15%' }}>Edit</TableTh>
<TableTh>Hapus</TableTh> <TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item, index) => ( {filteredData.length > 0 ? (
filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fz="sm">{(page - 1) * 10 + index + 1}</Text>
<Text truncate="end" fz={"sm"}>{index + 1}</Text>
</Box>
</TableTd> </TableTd>
<TableTd>{item.nama}</TableTd>
<TableTd> <TableTd>
<Button color='green' onClick={() => router.push(`/admin/desa/potensi/kategori-potensi/${item.id}`)}> <Text truncate lineClamp={1}>{item.nama}</Text>
</TableTd>
<TableTd>
<Button variant='light' color='green' onClick={() => router.push(`/admin/desa/potensi/kategori-potensi/${item.id}`)}>
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
variant='light'
color='red' color='red'
disabled={listDataState.delete.loading} disabled={listDataState.delete.loading}
onClick={() => { onClick={() => {
@@ -107,14 +112,32 @@ function ListKategoriPotensi({ search }: { search: string }) {
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">Tidak ada data kategori potensi yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */} <Center mt="md">
<Pagination
value={page}
onChange={(newPage) => load(newPage, 10, search)}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}

View File

@@ -5,7 +5,19 @@ import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import potensiDesaState from "@/app/admin/(dashboard)/_state/desa/potensi"; import potensiDesaState from "@/app/admin/(dashboard)/_state/desa/potensi";
import colors from "@/con/colors"; import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import {
Box,
Button,
Group,
Image,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react"; import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
@@ -13,38 +25,36 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
function EditPotensi() { function EditPotensi() {
const potensiState = useProxy(potensiDesaState.potensiDesa) const potensiState = useProxy(potensiDesaState.potensiDesa);
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: "",
deskripsi: '', deskripsi: "",
kategoriId: '', kategoriId: "",
content: '', content: "",
imageId: '' imageId: "",
}); });
useEffect(() => { useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load() potensiDesaState.kategoriPotensi.findMany.load();
const loadPotensi = async () => { const loadPotensi = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await potensiState.edit.load(id); // ambil data dari API const data = await potensiState.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name || "",
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || "",
kategoriId: data.kategoriId || '', kategoriId: data.kategoriId || "",
content: data.content || '', content: data.content || "",
imageId: data.imageId || '', imageId: data.imageId || "",
}); });
if (data?.image?.link) { if (data?.image?.link) {
@@ -62,13 +72,9 @@ function EditPotensi() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
// Sinkronkan semua data dari formData ke state global
potensiState.edit.form = { potensiState.edit.form = {
...potensiState.edit.form, ...potensiState.edit.form,
name: formData.name, ...formData,
deskripsi: formData.deskripsi,
kategoriId: formData.kategoriId,
content: formData.content,
}; };
if (file) { if (file) {
@@ -92,44 +98,52 @@ function EditPotensi() {
}; };
return ( return (
<Box> <Box px={{ base: "sm", md: "lg" }} py="md">
<Box mb={10}> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={25} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Paper bg={colors["white-1"]} p={"md"} w={{ base: "100%", md: "50%" }}> <Title order={4} ml="sm" c="dark">
<Stack gap={"xs"}> Edit Potensi Desa
<Title order={3}>Edit Potensi</Title> </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 <TextInput
label="Judul Potensi"
placeholder="Masukkan judul"
value={formData.name} value={formData.name}
onChange={(e) => { onChange={(e) => setFormData({ ...formData, name: e.target.value })}
const val = e.target.value; required
setFormData((prev) => ({ ...prev, name: val }));
potensiState.edit.form.name = val;
}}
label={<Text fz={"sm"} fw={"bold"}>Judul</Text>}
placeholder="masukkan judul"
/> />
<TextInput <TextInput
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi"
value={formData.deskripsi} value={formData.deskripsi}
onChange={(e) => { onChange={(e) => setFormData({ ...formData, deskripsi: e.target.value })}
const val = e.target.value; required
setFormData((prev) => ({ ...prev, deskripsi: val }));
potensiState.edit.form.deskripsi = val;
}}
label={<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>}
placeholder="masukkan deskripsi"
/> />
<Select <Select
value={formData.kategoriId} value={formData.kategoriId}
onChange={(val) => setFormData({ ...formData, kategoriId: val || "" })} onChange={(val) => setFormData({ ...formData, kategoriId: val || "" })}
label={<Text fw={"bold"} fz={"sm"}>Kategori</Text>} label="Kategori"
placeholder='Pilih kategori' placeholder="Pilih kategori"
data={ data={
potensiDesaState.kategoriPotensi.findMany.data?.map((v) => ({ potensiDesaState.kategoriPotensi.findMany.data?.map((v) => ({
value: v.id, value: v.id,
label: v.nama label: v.nama,
})) || [] })) || []
} }
clearable clearable
@@ -137,73 +151,86 @@ function EditPotensi() {
required required
error={!formData.kategoriId ? "Pilih kategori" : undefined} error={!formData.kategoriId ? "Pilih kategori" : undefined}
/> />
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box> Gambar Potensi
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error("File tidak valid, gunakan format gambar")}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ "image/*": [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag gambar ke sini atau klik untuk pilih file
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Maksimal 5MB dan harus format gambar Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
radius="md"
style={{ style={{
maxWidth: '100%', maxHeight: 220,
maxHeight: '200px', objectFit: "contain",
objectFit: 'contain', border: `1px solid ${colors["blue-button"]}`,
borderRadius: '8px',
border: '1px solid #ddd',
}} }}
/> />
</Box> </Box>
)} )}
</Box>
</Box>
</Box>
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Konten</Text> <Text fz="sm" fw="bold" mb={6}>
Konten Lengkap
</Text>
<EditEditor <EditEditor
value={formData.content} value={formData.content}
onChange={(htmlContent) => { onChange={(htmlContent) => setFormData({ ...formData, content: htmlContent })}
setFormData((prev) => ({ ...prev, content: htmlContent }));
potensiState.edit.form.content = htmlContent;
}}
/> />
</Box> </Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>Edit Potensi</Button>
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,122 +1,151 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import React from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi'; import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi';
export default function DetailPotensi() { export default function DetailPotensi() {
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const potensiState = useProxy(potensiDesaState.potensiDesa) const potensiState = useProxy(potensiDesaState.potensiDesa);
useShallowEffect(() => { useShallowEffect(() => {
potensiState.findUnique.load(params?.id as string) potensiState.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
potensiState.delete.byId(selectedId) potensiState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/potensi") router.push("/admin/desa/potensi");
}
} }
};
if (!potensiState.findUnique.data) { if (!potensiState.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
{Array.from({ length: 10 }).map((_, k) => ( <Skeleton height={500} radius="md" />
<Skeleton key={k} h={40} />
))}
</Stack> </Stack>
) );
} }
const data = potensiState.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Potensi</Text>
{potensiState.findUnique.data ? (
<Paper key={potensiState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fz={"lg"} fw={"bold"}>Judul</Text>
<Text fz={"lg"}>{potensiState.findUnique.data.name}</Text>
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Kategori</Text>
<Text fz={"lg"}>{potensiState.findUnique.data.kategori?.nama}</Text>
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Deskripsi</Text>
<Text fz={"lg"}>{potensiState.findUnique.data.deskripsi}</Text>
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Gambar</Text>
<Image src={potensiState.findUnique.data.image?.link} alt="gambar" />
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Konten</Text>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: potensiState.findUnique.data.content }} />
</Box>
<Box>
<Flex gap={"xs"}>
<Button <Button
onClick={() => { variant="subtle"
if (potensiState.findUnique.data) { onClick={() => router.back()}
setSelectedId(potensiState.findUnique.data.id) leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
setModalHapus(true) mb={15}
}
}}
disabled={potensiState.delete.loading || !potensiState.findUnique.data}
color="red"
> >
<IconX size={20} /> Kembali
</Button> </Button>
<Paper
withBorder
w={{ base: "100%", md: "60%" }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Potensi
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategori?.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz="md" c="dimmed">{data.deskripsi || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.name || 'Gambar Potensi'}
w={200}
h={200}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">Konten</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
/>
</Box>
<Group gap="sm">
<Tooltip label="Hapus Potensi" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (potensiState.findUnique.data) { setSelectedId(data.id);
router.push(`/admin/desa/potensi/list-potensi/${potensiState.findUnique.data.id}/edit`) setModalHapus(true);
}
}} }}
disabled={!potensiState.findUnique.data} variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Potensi" withArrow position="top">
<Button
color="green" color="green"
onClick={() =>
router.push(`/admin/desa/potensi/list-potensi/${data.id}/edit`)
}
variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Box> </Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus potensi ini?" text="Apakah Anda yakin ingin menghapus potensi ini?"
/> />
</Box> </Box>
); );
} }

View File

@@ -4,7 +4,19 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi'; import potensiDesaState from '@/app/admin/(dashboard)/_state/desa/potensi';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -12,8 +24,6 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePotensi() { function CreatePotensi() {
const potensiState = useProxy(potensiDesaState.potensiDesa); const potensiState = useProxy(potensiDesaState.potensiDesa);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -21,8 +31,8 @@ function CreatePotensi() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
potensiDesaState.kategoriPotensi.findMany.load() potensiDesaState.kategoriPotensi.findMany.load();
}, []) }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) return toast.warn('Pilih file gambar terlebih dahulu'); if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
@@ -59,34 +69,50 @@ function CreatePotensi() {
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> Tambah Potensi Desa
<Stack gap="xs"> </Title>
<Title order={3}>Create Potensi</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">
{/* Judul */}
<TextInput <TextInput
value={potensiState.create.form.name} value={potensiState.create.form.name}
onChange={(val) => (potensiState.create.form.name = val.target.value)} onChange={(val) => (potensiState.create.form.name = val.target.value)}
label={<Text fz="sm" fw="bold">Judul</Text>} label={<Text fz="sm" fw="bold">Judul</Text>}
placeholder="masukkan judul" placeholder="Masukkan judul potensi"
required
/> />
{/* Deskripsi */}
<TextInput <TextInput
value={potensiState.create.form.deskripsi} value={potensiState.create.form.deskripsi}
onChange={(val) => (potensiState.create.form.deskripsi = val.target.value)} onChange={(val) => (potensiState.create.form.deskripsi = val.target.value)}
label={<Text fz="sm" fw="bold">Deskripsi</Text>} label={<Text fz="sm" fw="bold">Deskripsi</Text>}
placeholder="masukkan deskripsi" placeholder="Masukkan deskripsi singkat"
required
/> />
{/* Kategori */}
<Select <Select
label={<Text fw={"bold"} fz={"sm"}>Kategori</Text>} label={<Text fz="sm" fw="bold">Kategori</Text>}
placeholder='Pilih kategori' placeholder="Pilih kategori"
value={potensiState.create.form.kategoriId || ""} value={potensiState.create.form.kategoriId || ""}
onChange={(val) => { onChange={(val) => {
potensiState.create.form.kategoriId = val ?? ""; potensiState.create.form.kategoriId = val ?? "";
@@ -97,65 +123,58 @@ function CreatePotensi() {
}))} }))}
/> />
{/* Upload Gambar */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>
<Box> Gambar Potensi
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
style={{ radius="md"
maxWidth: '100%', style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
maxHeight: '200px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
/> />
</Box> </Box>
)} )}
</Box>
</Box> </Box>
{/* Konten Editor */}
<Box> <Box>
<Text fz="sm" fw="bold">Konten</Text> <Text fz="sm" fw="bold" mb={6}>
Konten Lengkap
</Text>
<CreateEditor <CreateEditor
value={potensiState.create.form.content} value={potensiState.create.form.content}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -164,9 +183,21 @@ function CreatePotensi() {
/> />
</Box> </Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}> {/* Tombol Simpan */}
<Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan Potensi Simpan Potensi
</Button> </Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,25 +1,40 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip
} from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import potensiDesaState from '../../../_state/desa/potensi'; import potensiDesaState from '../../../_state/desa/potensi';
function Potensi() { function Potensi() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Posisi Organisasi' title='Potensi Desa'
placeholder='pencarian' placeholder='Cari potensi atau kategori...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -30,8 +45,8 @@ function Potensi() {
} }
function ListPotensi({ search }: { search: string }) { function ListPotensi({ search }: { search: string }) {
const potensiState = useProxy(potensiDesaState) const potensiState = useProxy(potensiDesaState);
const router = useRouter() const router = useRouter();
const { const {
data, data,
@@ -42,117 +57,108 @@ function ListPotensi({ search }: { search: string }) {
} = potensiState.potensiDesa.findMany; } = potensiState.potensiDesa.findMany;
useEffect(() => { useEffect(() => {
potensiState.kategoriPotensi.findMany.load() potensiState.kategoriPotensi.findMany.load();
load(page, 10) load(page, 10, search);
}, []) }, [page, search]);
const filteredData = (potensiState.potensiDesa.findMany.data || []).filter(item => { const filteredData = data || []
const keyword = search.toLowerCase();
return (
item.name.toLowerCase().includes(keyword) ||
item.kategori?.nama.toLowerCase().includes(keyword) ||
item.deskripsi.toLowerCase().includes(keyword)
);
});
// Handle loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton height={300} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
if (data.length === 0) {
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack> <Group justify="space-between" mb="md">
<JudulList <Title order={4}>Daftar Potensi Desa</Title>
title='List Potensi' <Tooltip label="Tambah Potensi" withArrow>
href='/admin/desa/potensi/list-potensi/create' <Button
/> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/potensi/list-potensi/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> <Table highlightOnHover style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Judul</TableTh> <TableTh style={{ width: '20%' }}>Judul</TableTh>
<TableTh>Kategori</TableTh> <TableTh style={{ width: '20%' }}>Kategori</TableTh>
<TableTh>Deskripsi</TableTh> <TableTh style={{ width: '35%' }}>Deskripsi</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '15%' }}>Detail</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
<TableTr> {filteredData.length > 0 ? (
<TableTd colSpan={4}>Tidak Ada Data</TableTd> filteredData.map((item) => (
</TableTr>
</TableTbody>
</Table>
</Box>
</Stack>
</Paper>
</Box>
)
}
return (
<Box py={10}>
<Paper bg={colors['white-1']} p={'md'}>
<Stack>
<JudulList
title='List Potensi'
href='/admin/desa/potensi/list-potensi/create'
/>
<Box style={{ overflowX: "auto" }}>
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}>
<TableThead>
<TableTr>
<TableTh>Judul</TableTh>
<TableTh>Kategori</TableTh>
<TableTh>Deskripsi</TableTh>
<TableTh>Detail</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fw={500} truncate="end" lineClamp={1}>
<Text truncate="end" fz={"sm"}>{item.name}</Text> {item.name}
</Box></TableTd> </Text>
<TableTd>{item.kategori?.nama}</TableTd> </TableTd>
<TableTd>
<Text fz="sm" c="dimmed">{item.kategori?.nama || '-'}</Text>
</TableTd>
<TableTd> <TableTd>
<Box w={300}> <Box w={300}>
<Text truncate="end" fz={"sm"} dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <Text
truncate
fz="sm"
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/desa/potensi/list-potensi/${item.id}`)}> <Button
<IconDeviceImacCog size={25} /> variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/potensi/list-potensi/${item.id}`)}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">Tidak ada data potensi yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
</Stack>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10); load(newPage, 10);
window.scrollTo(0, 0); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default Potensi; export default Potensi;

View File

@@ -1,9 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconUser, IconUsers, IconCalendar } from '@tabler/icons-react';
function LayoutTabsDetail({ children }: { children: React.ReactNode }) { function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -12,21 +13,28 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
{ {
label: "Profile Desa", label: "Profile Desa",
value: "profiledesa", value: "profiledesa",
href: "/admin/desa/profile/profile-desa" href: "/admin/desa/profile/profile-desa",
icon: <IconUser size={18} stroke={1.8} />,
tooltip: "Lihat dan kelola profil desa"
}, },
{ {
label: "Profile Perbekel", label: "Profile Perbekel",
value: "profileperbekel", value: "profileperbekel",
href: "/admin/desa/profile/profile-perbekel" href: "/admin/desa/profile/profile-perbekel",
icon: <IconUsers size={18} stroke={1.8} />,
tooltip: "Kelola data Perbekel"
}, },
{ {
label: "Profile Perbekel Dari Masa Ke Masa", label: "Profile Perbekel Dari Masa Ke Masa",
value: "profile-perbekel-dari-masa-ke-masa", value: "profile-perbekel-dari-masa-ke-masa",
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa" href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />,
tooltip: "Riwayat Perbekel dari masa ke masa"
} }
]; ];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value); 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 handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value) const tab = tabs.find(t => t.value === value)
@@ -44,22 +52,57 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
}, [pathname]) }, [pathname])
return ( return (
<Stack> <Stack gap="lg">
<Title order={3}>Profile Desa</Title> <Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Profile Desa</Title>
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}> <Tabs
<TabsList p={"xs"} bg={"#BBC8E7FF"}> color={colors['blue-button']}
{tabs.map((e, i) => ( variant='pills'
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
{tabs.map((e, i) => (
<TabsPanel key={i} value={e.value}> {tabs.map((tab, i) => (
{/* Konten dummy, bisa diganti tergantung routing */} <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)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
{children}
</Stack> </Stack>
); );
} }

View File

@@ -3,8 +3,8 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Alert, Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -15,7 +15,7 @@ function Page() {
const router = useRouter() const router = useRouter()
const params = useParams() const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Load data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
@@ -25,9 +25,12 @@ function Page() {
return; return;
} }
try {
const data = await lambangState.findUnique.load(id); const data = await lambangState.findUnique.load(id);
if (data) {
lambangState.update.initialize(data); lambangState.update.initialize(data);
} catch (error) {
console.error("Error loading lambang:", error);
toast.error("Gagal memuat data lambang desa");
} }
}; };
@@ -35,19 +38,21 @@ function Page() {
return () => { return () => {
lambangState.update.reset(); lambangState.update.reset();
lambangState.findUnique.reset(); // opsional: reset juga data lama lambangState.findUnique.reset();
}; };
}, [params?.id, router]); }, [params?.id, router]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !lambangState.update.form.judul.trim()) { if (isSubmitting || !lambangState.update.form.judul.trim()) {
toast.error("Judul wajib diisi"); toast.error("Judul wajib diisi");
return; return;
} }
setIsSubmitting(true)
setIsSubmitting(true);
try { try {
const success = await lambangState.update.submit() const success = await lambangState.update.submit();
if (success) { if (success) {
toast.success("Data berhasil disimpan"); toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profile/profile-desa");
@@ -58,17 +63,12 @@ function Page() {
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} };
const handleBack = () => { const handleBack = () => router.back();
router.back()
}
if ( // Loading state
lambangState.findUnique.loading || if (lambangState.findUnique.loading || lambangState.update.loading) {
!lambangState.findUnique.data ||
lambangState.update.loading
) {
return ( return (
<Box> <Box>
<Center h={400}> <Center h={400}>
@@ -77,27 +77,50 @@ function Page() {
</Box> </Box>
); );
} }
// Error state
if (lambangState.findUnique.error) {
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Stack gap="md">
<Group>
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{lambangState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Lambang Desa</Title>
</Group> </Group>
<Paper bg={colors['white-1']} p={'xs'} w={{ base: '100%', md: '50%' }}>
<Stack gap={'xs'}> <Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Box> <Stack gap="xs">
<Box>
<Stack>
<Title order={3}>Edit Lambang Desa</Title> <Title order={3}>Edit Lambang Desa</Title>
{/* Judul */}
<TextInput <TextInput
label={<Text fz={"md"} fw={"bold"}>Judul</Text>} label={<Text fw="bold">Judul</Text>}
placeholder="Judul" placeholder="Judul lambang"
value={lambangState.update.form.judul} value={lambangState.update.form.judul}
onChange={(e) => lambangState.update.form.judul = e.currentTarget.value} onChange={(e) => lambangState.update.form.judul = e.currentTarget.value}
error={!lambangState.update.form.judul && "Judul wajib diisi"} error={!lambangState.update.form.judul && "Judul wajib diisi"}
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text> <Text fz={"md"} fw={"bold"}>Deskripsi</Text>
<EditEditor <EditEditor
@@ -105,18 +128,23 @@ function Page() {
onChange={(val) => lambangState.update.form.deskripsi = val} onChange={(val) => lambangState.update.form.deskripsi = val}
/> />
</Box> </Box>
{/* Buttons */}
<Group> <Group>
<Button <Button
onClick={handleSubmit}
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || lambangState.update.loading}
disabled={!lambangState.update.form.judul}
> >
Submit {isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || lambangState.update.loading}>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Box>
</Box>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>

View File

@@ -5,38 +5,40 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Group, Image, Paper, SimpleGrid, Stack, Text, TextInput, Title, Tooltip, Center, Alert } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX, IconAlertCircle } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function Page() { function Page() {
const maskotState = useProxy(stateProfileDesa.maskotDesa) const maskotState = useProxy(stateProfileDesa.maskotDesa);
const router = useRouter() const router = useRouter();
const params = useParams() const params = useParams();
const [images, setImages] = useState<
Array<{ file: File; preview: string; label: string }>
>([]);
const [images, setImages] = useState<Array<{ file: File | null; preview: string; label: string }>>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: maskotState.update.form.judul || '', judul: '',
deskripsi: maskotState.update.form.deskripsi || '', deskripsi: '',
images: [] as Array<{ label: string; imageId: string }> images: [] as Array<{ label: string; imageId: string }>,
}) });
const [isSubmitting, setIsSubmitting] = useState(false);
// Load data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-desa");
return;
}
try { try {
const data = await maskotState.findUnique.load(id); const data = await maskotState.findUnique.load(id);
if (data) { if (data) {
// 🔥 INI YANG KURANG!
maskotState.update.initialize(data); maskotState.update.initialize(data);
setFormData({ setFormData({
@@ -57,28 +59,39 @@ function Page() {
} }
} }
} catch (error) { } catch (error) {
console.error("Error loading berita:", error); console.error("Error loading maskot:", error);
toast.error("Gagal memuat data berita"); toast.error("Gagal memuat data maskot");
} }
}; };
loadData(); loadData();
}, [params?.id]);
return () => {
maskotState.update.reset();
maskotState.findUnique.reset();
};
}, [params?.id, router]);
const handleBack = () => { const handleBack = () => router.back();
router.back()
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !formData.judul.trim()) {
toast.error("Judul wajib diisi");
return;
}
setIsSubmitting(true);
try { try {
const uploadedImages = []; const uploadedImages = [];
// Upload semua gambar baru // Upload semua gambar baru
for (const img of images) { for (const img of images) {
if (!img.file || !(img.file instanceof File)) { if (!img.file) {
toast.error("File tidak valid untuk di-upload"); // Kalau gambar lama, skip upload
continue; // atau return kalau kamu mau hentikan semua if (!img.preview) continue;
uploadedImages.push({ imageId: '', label: img.label });
continue;
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -92,10 +105,7 @@ function Page() {
return; return;
} }
uploadedImages.push({ uploadedImages.push({ imageId: uploaded.id, label: img.label || 'main' });
imageId: uploaded.id,
label: img.label || 'main',
});
} }
// Update ke global state // Update ke global state
@@ -109,44 +119,79 @@ function Page() {
toast.success("Maskot berhasil diperbarui!"); toast.success("Maskot berhasil diperbarui!");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profile/profile-desa");
} }
} catch (error) { } catch (error) {
console.error("Error update maskot:", error); console.error("Error update maskot:", error);
toast.error("Gagal update maskot"); toast.error("Gagal update maskot");
} finally {
setIsSubmitting(false);
} }
}; };
// Loading state
if (maskotState.findUnique.loading || maskotState.update.loading) {
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Center h={400}>
<Group> <Text>Memuat data...</Text>
</Center>
</Box>
);
}
// Error state
if (maskotState.findUnique.error) {
return (
<Box>
<Stack gap="md">
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{maskotState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Maskot Desa</Title>
</Group> </Group>
<Paper bg={colors['white-1']} p={'xs'} w={{ base: '100%', md: '100%' }}>
<Stack gap={'xs'}> <Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Box> <Stack gap="xs">
<Box>
<Stack>
<Title order={3}>Edit Maskot Desa</Title> <Title order={3}>Edit Maskot Desa</Title>
{/* Judul */}
<TextInput <TextInput
w={{ base: '100%', md: '50%' }} label={<Text fw="bold">Judul</Text>}
label={<Text fz={"md"} fw={"bold"}>Judul</Text>} placeholder="Masukkan judul maskot"
placeholder="Masukkan judul"
value={formData.judul} value={formData.judul}
onChange={(val) => setFormData({ ...formData, judul: val.currentTarget.value })} onChange={(e) => setFormData({ ...formData, judul: e.currentTarget.value })}
error={!formData.judul && "Judul wajib diisi"}
/> />
<Box w={{ base: '100%', md: '50%' }}>
{/* Deskripsi */}
<Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text> <Text fz={"md"} fw={"bold"}>Deskripsi</Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(val) => setFormData({ ...formData, deskripsi: val })} onChange={(val) => setFormData({ ...formData, deskripsi: val })}
/> />
</Box> </Box>
{/* Upload Gambar */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fz={"md"} fw={"bold"}>Gambar</Text>
<Box w={{ base: '100%', md: '50%' }}>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const newImages = files.map((file) => ({ const newImages = files.map((file) => ({
@@ -158,34 +203,24 @@ function Page() {
}} }}
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept> <Dropzone.Accept><IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /></Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <Dropzone.Reject><IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /></Dropzone.Reject>
</Dropzone.Accept> <Dropzone.Idle><IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /></Dropzone.Idle>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<div> <div>
<Text size="xl" inline> <Text size="xl" inline>Drag images here or click to select files</Text>
Drag images here or click to select files <Text size="sm" c="dimmed" inline mt={7}>Attach as many files as you like, each file max 5mb</Text>
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Attach as many files as you like, each file should not exceed 5mb
</Text>
</div> </div>
</Group> </Group>
</Dropzone> </Dropzone>
</Box> </Box>
</Box>
{/* Preview Gambar */}
<SimpleGrid cols={{ base: 2, md: 4 }}> <SimpleGrid cols={{ base: 2, md: 4 }}>
{images.map((img, index) => ( {images.map((img, index) => (
<Box key={index} mb="md"> <Box key={index} mb="md">
<Paper p="sm" radius="md" withBorder style={{ position: 'relative', maxWidth: 300 }}> <Paper p="sm" radius="md" withBorder style={{ position: 'relative', maxWidth: 300 }}>
<Stack gap={'xs'}> <Stack gap="xs">
<Group> <Group justify="space-between">
<Button <Button
size="xs" size="xs"
color="red" color="red"
@@ -222,18 +257,22 @@ function Page() {
</Box> </Box>
))} ))}
</SimpleGrid> </SimpleGrid>
{/* Buttons */}
<Group> <Group>
<Button <Button
onClick={handleSubmit}
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || maskotState.update.loading}
disabled={!formData.judul}
> >
Submit {isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || maskotState.update.loading}>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Box>
</Box>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>

View File

@@ -3,8 +3,8 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Alert, Box, Button, Center, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -16,6 +16,7 @@ function Page() {
const params = useParams() const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Load data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
@@ -25,29 +26,34 @@ function Page() {
return; return;
} }
try {
const data = await sejarahState.findUnique.load(id); const data = await sejarahState.findUnique.load(id);
if (data) { if (data) {
sejarahState.update.initialize(data); sejarahState.update.initialize(data);
} }
} catch (error) {
console.error("Error loading sejarah:", error);
toast.error("Gagal memuat data sejarah desa");
}
}; };
loadData(); loadData();
return () => { return () => {
sejarahState.update.reset(); sejarahState.update.reset();
sejarahState.findUnique.reset(); // opsional: reset juga data lama sejarahState.findUnique.reset();
}; };
}, [params?.id, router]); }, [params?.id, router]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !sejarahState.update.form.judul.trim()) { if (isSubmitting || !sejarahState.update.form.judul.trim()) {
toast.error("Judul wajib diisi"); toast.error("Judul wajib diisi");
return; return;
} }
setIsSubmitting(true)
setIsSubmitting(true);
try { try {
const success = await sejarahState.update.submit() const success = await sejarahState.update.submit();
if (success) { if (success) {
toast.success("Data berhasil disimpan"); toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profile/profile-desa");
@@ -58,17 +64,12 @@ function Page() {
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} };
const handleBack = () => { const handleBack = () => router.back();
router.back()
}
if ( // Loading state
sejarahState.findUnique.loading || if (sejarahState.findUnique.loading || sejarahState.update.loading) {
!sejarahState.findUnique.data ||
sejarahState.update.loading
) {
return ( return (
<Box> <Box>
<Center h={400}> <Center h={400}>
@@ -77,27 +78,50 @@ function Page() {
</Box> </Box>
); );
} }
// Error state
if (sejarahState.findUnique.error) {
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Stack gap="md">
<Group>
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{sejarahState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Sejarah Desa</Title>
</Group> </Group>
<Paper bg={colors['white-1']} p={'xs'} w={{ base: '100%', md: '50%' }}>
<Stack gap={'xs'}> <Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Box> <Stack gap="xs">
<Box>
<Stack>
<Title order={3}>Edit Sejarah Desa</Title> <Title order={3}>Edit Sejarah Desa</Title>
{/* Judul */}
<TextInput <TextInput
label={<Text fz={"md"} fw={"bold"}>Judul</Text>} label={<Text fw="bold">Judul</Text>}
placeholder="Judul" placeholder="Judul sejarah"
value={sejarahState.update.form.judul} value={sejarahState.update.form.judul}
onChange={(e) => sejarahState.update.form.judul = e.currentTarget.value} onChange={(e) => sejarahState.update.form.judul = e.currentTarget.value}
error={!sejarahState.update.form.judul && "Judul wajib diisi"} error={!sejarahState.update.form.judul && "Judul wajib diisi"}
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi</Text> <Text fz={"md"} fw={"bold"}>Deskripsi</Text>
<EditEditor <EditEditor
@@ -105,18 +129,23 @@ function Page() {
onChange={(val) => sejarahState.update.form.deskripsi = val} onChange={(val) => sejarahState.update.form.deskripsi = val}
/> />
</Box> </Box>
{/* Buttons */}
<Group> <Group>
<Button <Button
onClick={handleSubmit}
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || sejarahState.update.loading}
disabled={!sejarahState.update.form.judul}
> >
Submit {isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || sejarahState.update.loading}>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Box>
</Box>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>

View File

@@ -3,8 +3,8 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Paper, Stack, Text, Title } from '@mantine/core'; import { Alert, Box, Button, Center, Group, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconAlertCircle, IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -16,6 +16,7 @@ function Page() {
const params = useParams() const params = useParams()
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Load data
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
@@ -25,9 +26,12 @@ function Page() {
return; return;
} }
try {
const data = await visiMisiState.findUnique.load(id); const data = await visiMisiState.findUnique.load(id);
if (data) {
visiMisiState.update.initialize(data); visiMisiState.update.initialize(data);
} catch (error) {
console.error("Error loading visi misi:", error);
toast.error("Gagal memuat data visi misi desa");
} }
}; };
@@ -35,40 +39,37 @@ function Page() {
return () => { return () => {
visiMisiState.update.reset(); visiMisiState.update.reset();
visiMisiState.findUnique.reset(); // opsional: reset juga data lama visiMisiState.findUnique.reset();
}; };
}, [params?.id, router]); }, [params?.id, router]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !visiMisiState.update.form.visi.trim()) { if (isSubmitting || !visiMisiState.update.form.visi.trim()) {
toast.error("Visi wajib diisi"); toast.error("Visi wajib diisi");
return; return;
} }
setIsSubmitting(true)
setIsSubmitting(true);
try { try {
const success = await visiMisiState.update.submit() const success = await visiMisiState.update.submit();
if (success) { if (success) {
toast.success("Data berhasil disimpan"); toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-desa"); router.push("/admin/desa/profile/profile-desa");
} }
} catch (error) { } catch (error) {
console.error("Error update sejarah desa:", error); console.error("Error update visi misi desa:", error);
toast.error("Terjadi kesalahan saat update sejarah desa"); toast.error("Terjadi kesalahan saat update visi misi desa");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} };
const handleBack = () => { const handleBack = () => router.back();
router.back()
}
if ( // Loading state
visiMisiState.findUnique.loading || if (visiMisiState.findUnique.loading || visiMisiState.update.loading) {
!visiMisiState.findUnique.data ||
visiMisiState.update.loading
) {
return ( return (
<Box> <Box>
<Center h={400}> <Center h={400}>
@@ -77,25 +78,50 @@ function Page() {
</Box> </Box>
); );
} }
// Error state
if (visiMisiState.findUnique.error) {
return ( return (
<Box> <Box>
<Stack gap={'xs'}> <Stack gap="md">
<Group>
<Button variant="subtle" onClick={handleBack}> <Button variant="subtle" onClick={handleBack}>
<IconArrowBack color={colors['blue-button']} size={20} /> <IconArrowBack color={colors['blue-button']} size={20} />
</Button> </Button>
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text fw="bold">Error</Text>
<Text>{visiMisiState.findUnique.error}</Text>
</Alert>
</Stack>
</Box>
);
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">Edit Visi Misi Desa</Title>
</Group> </Group>
<Paper bg={colors['white-1']} p={'xs'} w={{ base: '100%', md: '50%' }}>
<Stack gap={'xs'}> <Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p="md" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Box> <Stack gap="xs">
<Box>
<Stack>
<Title order={3}>Edit Visi Misi Desa</Title> <Title order={3}>Edit Visi Misi Desa</Title>
{/* Visi */}
<Box>
<Text fz={"md"} fw={"bold"}>Visi</Text> <Text fz={"md"} fw={"bold"}>Visi</Text>
<EditEditor <EditEditor
value={visiMisiState.update.form.visi} value={visiMisiState.update.form.visi}
onChange={(val) => visiMisiState.update.form.visi = val} onChange={(val) => visiMisiState.update.form.visi = val}
/> />
</Box>
{/* Misi */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Misi</Text> <Text fz={"md"} fw={"bold"}>Misi</Text>
<EditEditor <EditEditor
@@ -103,18 +129,23 @@ function Page() {
onChange={(val) => visiMisiState.update.form.misi = val} onChange={(val) => visiMisiState.update.form.misi = val}
/> />
</Box> </Box>
{/* Buttons */}
<Group> <Group>
<Button <Button
onClick={handleSubmit}
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || visiMisiState.update.loading}
disabled={!visiMisiState.update.form.visi}
> >
Submit {isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || visiMisiState.update.loading}>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Box>
</Box>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Card, Center, Grid, GridCol, Group, Image, Paper, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Card, Center, Divider, Grid, GridCol, Image, Paper, SimpleGrid, Stack, Text, Title, Tooltip } from '@mantine/core';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import stateProfileDesa from '../../../_state/desa/profile'; import stateProfileDesa from '../../../_state/desa/profile';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -12,7 +12,6 @@ function Page() {
const router = useRouter(); const router = useRouter();
const snap = useSnapshot(stateProfileDesa); const snap = useSnapshot(stateProfileDesa);
// Panggil load data sekali saat komponen mount
useEffect(() => { useEffect(() => {
stateProfileDesa.sejarahDesa.findUnique.load("edit"); stateProfileDesa.sejarahDesa.findUnique.load("edit");
stateProfileDesa.visiMisiDesa.findUnique.load("edit"); stateProfileDesa.visiMisiDesa.findUnique.load("edit");
@@ -26,142 +25,219 @@ function Page() {
const maskot = snap.maskotDesa.findUnique.data; const maskot = snap.maskotDesa.findUnique.data;
return ( return (
<Paper bg={colors['white-1']} p={'md'}> <Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap={"lg"}> <Stack gap="lg">
<Title order={2}>Preview Profile Desa</Title> <Title order={2} c={colors['blue-button']}>Preview Profile Desa</Title>
{/* Sejarah Desa */} {/* Sejarah Desa */}
{sejarah && ( {sejarah && (
<Box> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Stack gap={'lg'}> <Grid align="center">
<Paper p={"md"} bg={colors['BG-trans']}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={2}>Preview Sejarah Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Sejarah Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button bg={colors['blue-button']} onClick={() => router.push(`/admin/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}> <Tooltip label="Edit Sejarah Desa" withArrow>
<IconEdit size={20} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
<Box pb={30}>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center> <Center>
<Image src={"/darmasaba-icon.png"} alt="" w={{ base: 200, md: 300 }} /> <Image src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center> </Center>
<Text c={colors['blue-button']} ta={"center"} fw={"bold"} fz={"2.5rem"}>{sejarah.judul}</Text> <Paper
</Box> bg={colors['blue-button']}
<Paper p={"xl"} bg={colors['white-trans-1']}> py="md"
<Text fz={{ base: "md", md: "h3" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: sejarah.deskripsi }} /> px="sm"
</Paper> radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
{sejarah.judul}
</Text>
</Paper> </Paper>
</Stack> </Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} ta="justify" dangerouslySetInnerHTML={{ __html: sejarah.deskripsi }} />
</Paper>
</Box> </Box>
</Paper>
)} )}
{/* Visi Misi Desa */} {/* Visi Misi Desa */}
{visiMisi && ( {visiMisi && (
<Box> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Stack gap={'lg'}> <Grid align="center">
<Paper p={"md"} bg={colors['BG-trans']}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={2}>Preview Visi Misi Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Visi Misi Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button onClick={() => router.push(`/admin/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)} bg={colors['blue-button']}> <Tooltip label="Edit Visi Misi Desa" withArrow>
<IconEdit size={20} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
<Box pb={30}>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center> <Center>
<Image src={"/darmasaba-icon.png"} alt="" w={{ base: 200, md: 300 }} /> <Image src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center> </Center>
<Text c={colors['blue-button']} ta={"center"} fw={"bold"} fz={"2.5rem"}>Visi Misi Desa</Text> <Paper
</Box> bg={colors['blue-button']}
<Paper p={"xl"} bg={colors['white-trans-1']}> py="md"
<Text fw={"bold"} fz={{ base: "lg", md: "h2" }}>Visi Desa</Text> px="sm"
<Text fz={{ base: "md", md: "h3" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: visiMisi.visi }} /> radius="md"
<Text fw={"bold"} fz={{ base: "lg", md: "h2" }}>Misi Desa</Text> style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
<Text fz={{ base: "md", md: "h3" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: visiMisi.misi }} /> >
</Paper> <Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Visi Misi Desa
</Text>
</Paper> </Paper>
</Stack> </Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Visi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" dangerouslySetInnerHTML={{ __html: visiMisi.visi }} />
<Text fw="bold" fz={{ base: "lg", md: "h2" }}>Misi Desa</Text>
<Text fz={{ base: "md", md: "h3" }} ta="justify" dangerouslySetInnerHTML={{ __html: visiMisi.misi }} />
</Paper>
</Box> </Box>
</Paper>
)} )}
{/* Lambang Desa */} {/* Lambang Desa */}
{lambang && ( {lambang && (
<Box> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Stack gap={'lg'}> <Grid align="center">
<Paper p={"md"} bg={colors['BG-trans']}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={2}>Preview Lambang Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Lambang Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button onClick={() => router.push(`/admin/desa/profile/profile-desa/${lambang.id}/lambang_desa`)} bg={colors['blue-button']}> <Tooltip label="Edit Lambang Desa" withArrow>
<IconEdit size={20} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${lambang.id}/lambang_desa`)}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
<Box pb={30}>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center> <Center>
<Image src={"/darmasaba-icon.png"} alt="" w={{ base: 200, md: 300 }} /> <Image src="/darmasaba-icon.png" w={{ base: 150, md: 250 }} alt="Logo Desa" />
</Center> </Center>
<Text c={colors['blue-button']} ta={"center"} fw={"bold"} fz={"2.5rem"}>Lambang Desa</Text> <Paper
</Box> bg={colors['blue-button']}
<Paper p={"xl"} bg={colors['white-trans-1']}> py="md"
<Text fz={{ base: "md", md: "h3" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: lambang.deskripsi }} /> px="sm"
</Paper> radius="md"
style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Lambang Desa
</Text>
</Paper> </Paper>
</Stack> </Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} ta="justify" dangerouslySetInnerHTML={{ __html: lambang.deskripsi }} />
</Paper>
</Box> </Box>
</Paper>
)} )}
{/* Maskot Desa */} {/* Maskot Desa */}
{maskot && ( {maskot && (
<Box> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Stack gap={'lg'}> <Grid align="center">
<Paper p={"md"} bg={colors['BG-trans']}>
<Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={2}>Preview Maskot Desa</Title> <Title order={3} c={colors['blue-button']}>Preview Maskot Desa</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button onClick={() => router.push(`/admin/desa/profile/profile-desa/${maskot.id}/maskot_desa`)} bg={colors['blue-button']}> <Tooltip label="Edit Maskot Desa" withArrow>
<IconEdit size={20} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${maskot.id}/maskot_desa`)}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
<Box pb={30}>
<Box px={{ base: 0, md: 50 }} py="xl">
<Paper bg={colors['white-1']} withBorder radius="md" shadow="xs" p="lg">
<Stack gap={0}>
<Center> <Center>
<Image src={"/pudak-icon.png"} alt="" w={{ base: 200, md: 300 }} /> <Image src="/pudak-icon.png" w={{ base: 150, md: 250 }} alt="Maskot Desa" />
</Center> </Center>
<Text c={colors['blue-button']} ta={"center"} fw={"bold"} fz={"2.5rem"}>Maskot Desa</Text> <Paper
</Box> bg={colors['blue-button']}
<Paper p={"xl"} bg={colors['white-trans-1']}> py="md"
<Text fz={{ base: "md", md: "h3" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: maskot.deskripsi }} /> px="sm"
<Group wrap="wrap" gap="md"> radius="md"
{maskot.images.map((img, index) => ( style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
<Card key={index} p="xs" w={220}> >
<Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
Maskot Desa
</Text>
</Paper>
</Stack>
<Divider my="md" color={colors['blue-button']} />
<Text fz={{ base: "md", md: "h3" }} ta="justify" dangerouslySetInnerHTML={{ __html: maskot.deskripsi }} />
<Stack mt="md" gap="sm">
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{maskot.images.map((img, idx) => (
<Card withBorder key={idx} p="xs" w={{ base: '100%', md: 180 }}>
<Center>
<Image <Image
src={img.image.link} src={img.image.link}
alt={img.label} alt={img.label}
w={200} w={150}
h={200} h={150}
fit="cover" fit="cover"
radius="md" radius="md"
style={{ border: '1px solid #ccc', objectFit: 'cover' }} style={{ border: '1px solid #ccc' }}
/> />
</Center>
<Text ta="center" mt="xs" fw="bold">{img.label}</Text> <Text ta="center" mt="xs" fw="bold">{img.label}</Text>
</Card> </Card>
))} ))}
</Group> </SimpleGrid>
</Paper>
</Paper>
</Stack> </Stack>
</Paper>
</Box> </Box>
</Paper>
)} )}
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -3,17 +3,27 @@
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Image,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function EditPerbekelDariMasaKeMasa() { function EditPerbekelDariMasaKeMasa() {
const state = useProxy(stateProfileDesa.mantanPerbekel) const state = useProxy(stateProfileDesa.mantanPerbekel);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -38,9 +48,7 @@ function EditPerbekelDariMasaKeMasa() {
periode: data.periode || '', periode: data.periode || '',
imageId: data.imageId || '' imageId: data.imageId || ''
}); });
if (data?.imageGalleryFoto?.link) { if (data?.imageGalleryFoto?.link) setPreviewImage(data.imageGalleryFoto.link);
setPreviewImage(data.imageGalleryFoto.link);
}
} }
} catch (error) { } catch (error) {
console.error('Error loading foto:', error); console.error('Error loading foto:', error);
@@ -52,24 +60,18 @@ function EditPerbekelDariMasaKeMasa() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
state.update.form = { state.update.form = { ...state.update.form, ...formData };
...state.update.form,
nama: formData.nama,
daerah: formData.daerah,
periode: formData.periode,
imageId: formData.imageId
};
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file, file,
name: file.name, name: file.name,
}); });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) return toast.error("Gagal upload gambar");
return toast.error("Gagal upload gambar");
}
state.update.form.imageId = uploaded.id; state.update.form.imageId = uploaded.id;
} }
await state.update.update(); await state.update.update();
toast.success('Perbekel dari masa ke masa berhasil diperbarui!'); toast.success('Perbekel dari masa ke masa berhasil diperbarui!');
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa'); router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
@@ -80,86 +82,119 @@ function EditPerbekelDariMasaKeMasa() {
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> <Group mb="md">
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={25} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Perbekel Dari Masa Ke Masa
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> <Paper
<Stack gap={"xs"}> w={{ base: '100%', md: '50%' }}
<Title order={4}>Edit Perbekel Dari Masa Ke Masa</Title> bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Nama</Text>} label="Nama"
placeholder='Masukkan nama' placeholder="Masukkan nama"
value={formData.nama} value={formData.nama}
onChange={(e) => onChange={(e) => setFormData({ ...formData, nama: e.target.value })}
(formData.nama = e.target.value) required
}
/> />
<Box> <Box>
<Text>Upload Foto</Text> <Text fw="bold" fz="sm" mb={6}>
Foto Perbekel
</Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<Stack gap="xs" align="center">
<div> <Text size="md" fw={500}>
<Text size="xl" inline> Seret gambar atau klik untuk memilih file
Drag gambar ke sini atau klik untuk pilih file
</Text> </Text>
<Text size="sm" c="dimmed" inline mt={7}> <Text size="sm" c="dimmed">
Maksimal 5MB dan harus format gambar Maksimal 5MB, format gambar wajib
</Text> </Text>
</div> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{previewImage ? ( {previewImage && (
<Image alt="" src={previewImage} w={200} h={200} /> <Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
) : ( <Image
<Center w={200} h={200} bg={"gray"}> src={previewImage}
<IconImageInPicture /> alt="Preview Gambar"
</Center> radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
/>
</Box>
)} )}
</Box> </Box>
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Daerah</Text>} label="Daerah"
placeholder='Masukkan daerah' placeholder="Masukkan daerah"
value={formData.daerah} value={formData.daerah}
onChange={(e) => onChange={(e) => setFormData({ ...formData, daerah: e.target.value })}
(formData.daerah = e.target.value) required
}
/> />
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Periode</Text>} label="Periode"
placeholder='Masukkan periode' placeholder="Masukkan periode"
value={formData.periode} value={formData.periode}
onChange={(e) => onChange={(e) => setFormData({ ...formData, periode: e.target.value })}
(formData.periode = e.target.value) required
}
/> />
<Group>
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -2,7 +2,7 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -10,99 +10,130 @@ import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailPerbekelDariMasa() { function DetailPerbekelDariMasa() {
const state = useProxy(stateProfileDesa.mantanPerbekel) const state = useProxy(stateProfileDesa.mantanPerbekel);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => { useShallowEffect(() => {
state.findUnique.load(params?.id as string) state.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
state.delete.byId(selectedId) state.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa") router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa");
}
} }
};
if (!state.findUnique.data) { if (!state.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = state.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
</Button>
</Box>
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
<Stack>
<Text fz={"xl"} fw={"bold"}>Detail Perbekel Dari Masa Ke Masa</Text>
{state.findUnique.data ? (
<Paper key={state.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fw={"bold"} fz={"lg"}>Nama Perbekel</Text>
<Text fz={"lg"}>{state.findUnique.data?.nama}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Daerah</Text>
<Text fz={"lg"}>{state.findUnique.data?.daerah}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Periode</Text>
<Text fz={"lg"}>{state.findUnique.data?.periode}</Text>
</Box>
<Box>
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
<Image w={{ base: 300, md: 350}} src={state.findUnique.data?.image?.link} alt="gambar" />
</Box>
<Flex gap={"xs"} mt={10}>
<Button <Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: "100%", md: "60%" }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Perbekel Dari Masa Ke Masa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.nama || 'Gambar Perbekel'}
w={150}
h={150}
radius="md"
fit="cover"
/>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">Nama Perbekel</Text>
<Text fz="md" c="dimmed">{data.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Daerah</Text>
<Text fz="md" c="dimmed">{data.daerah || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Periode</Text>
<Text fz="md" c="dimmed">{data.periode || '-'}</Text>
</Box>
<Group gap="sm">
<Tooltip label="Hapus Perbekel" withArrow position="top">
<Button
color="red"
onClick={() => { onClick={() => {
if (state.findUnique.data) { setSelectedId(data.id);
setSelectedId(state.findUnique.data.id);
setModalHapus(true); setModalHapus(true);
}
}} }}
disabled={state.delete.loading || !state.findUnique.data} variant="light"
color={"red"} radius="md"
size="md"
> >
<IconX size={20} /> <IconX size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Perbekel" withArrow position="top">
<Button <Button
onClick={() => { color="green"
if (state.findUnique.data) { onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${state.findUnique.data.id}/edit`); variant="light"
} radius="md"
}} size="md"
disabled={!state.findUnique.data}
color={"green"}
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Group>
</Stack> </Stack>
</Paper> </Paper>
) : null}
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus perbekel dari masa ke masa ini?' text="Apakah Anda yakin ingin menghapus perbekel dari masa ke masa ini?"
/> />
</Box> </Box>
); );

View File

@@ -1,8 +1,8 @@
'use client' 'use client';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -10,29 +10,26 @@ import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePerbekelDariMasaKeMasa() {
const state = useProxy(stateProfileDesa.mantanPerbekel);
function CreateVideo() {
const state = useProxy(stateProfileDesa.mantanPerbekel)
const router = useRouter(); const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const resetForm = () => { const resetForm = () => {
state.create.form = { state.create.form = {
nama: "", nama: '',
daerah: "", daerah: '',
periode: "", periode: '',
imageId: "", imageId: '',
}; };
setPreviewImage(null) setPreviewImage(null);
setFile(null) setFile(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!file) { if (!file) {
return toast.warn("Pilih file gambar terlebih dahulu"); return toast.warn('Pilih file gambar terlebih dahulu');
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -41,110 +38,118 @@ function CreateVideo() {
}); });
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) return toast.error('Gagal upload gambar');
return toast.error("Gagal upload gambar");
}
state.create.form.imageId = uploaded.id; state.create.form.imageId = uploaded.id;
await state.create.create(); await state.create.create();
resetForm(); resetForm();
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa"); router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Back button + Title */}
<Button onClick={() => router.back()} variant='subtle' color={'blue'}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> </Tooltip>
<Title order={4} ml="sm" c="dark">
Create Perbekel Dari Masa Ke Masa
</Title>
</Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p={'md'}> <Paper
<Stack gap={"xs"}> w={{ base: '100%', md: '50%' }}
<Title order={4}>Create Perbekel Dari Masa Ke Masa</Title> bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Nama Perbekel</Text>} label={<Text fw="bold" fz="sm">Nama Perbekel</Text>}
placeholder='Masukkan nama perbekel' placeholder="Masukkan nama perbekel"
value={state.create.form.nama} value={state.create.form.nama}
onChange={(val) => { onChange={(e) => (state.create.form.nama = e.target.value)}
state.create.form.nama = val.target.value;
}}
/>
<TextInput
label="Daerah"
placeholder="Masukkan daerah"
value={state.create.form.daerah}
onChange={(e) => {
state.create.form.daerah = e.currentTarget.value;
}}
required required
/> />
<TextInput <TextInput
label={<Text fw={"bold"} fz={"sm"}>Periode</Text>} label={<Text fw="bold" fz="sm">Daerah</Text>}
placeholder='Masukkan periode' placeholder="Masukkan daerah"
value={state.create.form.periode} value={state.create.form.daerah}
onChange={(e) => onChange={(e) => (state.create.form.daerah = e.target.value)}
(state.create.form.periode = e.target.value) required
}
/> />
<TextInput
label={<Text fw="bold" fz="sm">Periode</Text>}
placeholder="Masukkan periode"
value={state.create.form.periode}
onChange={(e) => (state.create.form.periode = e.target.value)}
required
/>
{/* Dropzone */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fw="bold" fz="sm" mb={6}>Gambar Perbekel</Text>
<Box>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
radius="md"
p="xl"
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle> </Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div>
</Group> </Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */}
{previewImage && ( {previewImage && (
<Box mt="sm"> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview" alt="Preview Gambar"
style={{ radius="md"
maxWidth: '100%', style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
maxHeight: '200px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
/> />
</Box> </Box>
)} )}
</Box>
</Box> {/* Submit */}
</Box> <Group justify="right">
<Group> <Button
<Button onClick={handleSubmit} bg={colors['blue-button']}>Submit</Button> onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
@@ -152,4 +157,4 @@ function CreateVideo() {
); );
} }
export default CreateVideo; export default CreatePerbekelDariMasaKeMasa;

View File

@@ -1,13 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import stateProfileDesa from '../../../_state/desa/profile'; import stateProfileDesa from '../../../_state/desa/profile';
function PerbekelDariMasaKeMasa() { function PerbekelDariMasaKeMasa() {
@@ -16,7 +15,7 @@ function PerbekelDariMasaKeMasa() {
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Perbekel Dari Masa Ke Masa' title='Perbekel Dari Masa Ke Masa'
placeholder='pencarian' placeholder='Cari nama perbekel...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -29,74 +28,96 @@ function PerbekelDariMasaKeMasa() {
function ListPerbekelDariMasaKeMasa({ search }: { search: string }) { function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
const state = useProxy(stateProfileDesa.mantanPerbekel) const state = useProxy(stateProfileDesa.mantanPerbekel)
const router = useRouter(); const router = useRouter();
const { const { data, page, totalPages, loading, load } = state.findMany;
data,
page,
totalPages,
loading,
load,
} = state.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, search)
}, [page, search]) }, [page, search]);
const filteredData = (data || []) const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Box py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Box> </Stack>
) );
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<JudulList <Group justify='space-between' mb="md">
title='List Perbekel Dari Masa Ke Masa' <Title order={4}>List Perbekel Dari Masa Ke Masa</Title>
href='/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create' <Tooltip label="Tambah Perbekel Baru" withArrow>
/> <Button
<Table striped withTableBorder withRowBorders> leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create')}
>
Tambah Baru
</Button>
</Tooltip>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Nama Perbekel</TableTh> <TableTh style={{ width: '35%' }}>Nama Perbekel</TableTh>
<TableTh>Periode</TableTh> <TableTh style={{ width: '35%' }}>Periode</TableTh>
<TableTh>Detail</TableTh> <TableTh style={{ width: '20%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.map((item) => ( {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={200}> <Text fw={500} lineClamp={1}>{item.nama}</Text>
<Text lineClamp={1}>{item.nama}</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}>
<Text lineClamp={1}>{item.periode}</Text> <Text lineClamp={1}>{item.periode}</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}> <Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}
>
<IconDeviceImac size={20} /> <IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">Tidak ada data perbekel yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody> </TableTbody>
</Table> </Table>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -4,9 +4,9 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile'; import stateProfileDesa from '@/app/admin/(dashboard)/_state/desa/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Center, Group, Image, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX, IconImageInPicture } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -30,22 +30,29 @@ function ProfilePerbekel() {
} }
const data = await perbekelState.findUnique.load(id); const data = await perbekelState.findUnique.load(id);
if (data) { if (data) perbekelState.edit.initialize(data);
perbekelState.edit.initialize(data); if (data?.image?.link) setPreviewImage(data.image.link);
}
if (data?.image?.link) {
setPreviewImage(data.image.link);
}
}; };
loadData(); loadData();
return () => { return () => {
perbekelState.edit.reset(); perbekelState.edit.reset();
perbekelState.findUnique.reset(); // opsional: reset juga data lama perbekelState.findUnique.reset();
}; };
}, [params?.id, router]); }, [params?.id, router]);
const handleFileChange = (newFile: File | null) => {
if (!newFile) {
setFile(null);
setPreviewImage(null);
return;
}
setFile(newFile);
const reader = new FileReader();
reader.onload = (event) => setPreviewImage(event.target?.result as string);
reader.readAsDataURL(newFile);
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting || !perbekelState.edit.form.biodata.trim()) { if (isSubmitting || !perbekelState.edit.form.biodata.trim()) {
@@ -62,7 +69,6 @@ function ProfilePerbekel() {
toast.error("Gagal upload gambar"); toast.error("Gagal upload gambar");
return; return;
} }
perbekelState.edit.form.imageId = uploaded.id; perbekelState.edit.form.imageId = uploaded.id;
} }
const success = await perbekelState.edit.submit() const success = await perbekelState.edit.submit()
@@ -78,15 +84,9 @@ function ProfilePerbekel() {
} }
} }
const handleBack = () => { const handleBack = () => router.back();
router.back()
}
if ( if (perbekelState.findUnique.loading || perbekelState.edit.loading) {
perbekelState.findUnique.loading ||
!perbekelState.findUnique.data ||
perbekelState.edit.loading
) {
return ( return (
<Box> <Box>
<Center h={400}> <Center h={400}>
@@ -98,117 +98,112 @@ function ProfilePerbekel() {
return ( return (
<Box py={10}> <Box py={10}>
<Stack gap={'xs'}> <Stack gap="xs">
<Group> {/* Header */}
<Button variant="subtle" onClick={handleBack}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={20} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={handleBack} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Profil Perbekel
</Title>
</Group> </Group>
<Paper bg={colors['white-1']} p={'xs'} w={{ base: '100%', md: '50%' }}>
<Stack gap={'xs'}> <Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }} radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}>
<Stack gap="xs">
{/* Biodata */}
<Box> <Box>
<Box> <Text fz="md" fw="bold">Biodata</Text>
<Stack>
<Title order={3}>Edit Profil Perbekel</Title>
<Text fz={"md"} fw={"bold"}>Biodata</Text>
<EditEditor <EditEditor
value={perbekelState.edit.form.biodata} value={perbekelState.edit.form.biodata}
onChange={(val) => perbekelState.edit.form.biodata = val} onChange={(val) => perbekelState.edit.form.biodata = val}
/> />
</Box>
{/* Gambar */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text> <Text fz="md" fw="bold">Gambar</Text>
<Box>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => handleFileChange(files[0])}
const selectedFile = files[0]; // Ambil file pertama
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview
}
}}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2} // 5MB
accept={{ 'image/*': [] }} accept={{ 'image/*': [] }}
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept> <Dropzone.Accept><IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /></Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <Dropzone.Reject><IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /></Dropzone.Reject>
</Dropzone.Accept> <Dropzone.Idle><IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /></Dropzone.Idle>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<div> <div>
<Text size="xl" inline> <Text size="xl" inline>Drag gambar ke sini atau klik untuk pilih file</Text>
Drag gambar ke sini atau klik untuk pilih file <Text size="sm" c="dimmed" inline mt={7}>Maksimal 5MB dan harus format gambar</Text>
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
</Text>
</div> </div>
</Group> </Group>
</Dropzone> </Dropzone>
{/* Tampilkan preview kalau ada */} {/* Preview */}
{previewImage && (
<Box mt="sm"> <Box mt="sm">
<Image {previewImage ? (
src={previewImage} <Image src={previewImage} alt="Preview" w={200} h={200} fit="cover" radius="md" />
alt="Preview" ) : (
style={{ <Center w={200} h={200} bg="gray.2">
maxWidth: '100%', <Stack align="center" gap="xs">
maxHeight: '200px', <IconImageInPicture size={48} color="gray" />
objectFit: 'contain', <Text size="sm" c="gray">Tidak ada gambar</Text>
borderRadius: '8px', </Stack>
border: '1px solid #ddd', </Center>
}}
/>
</Box>
)} )}
</Box> </Box>
</Box> </Box>
{/* Pengalaman */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Pengalaman</Text> <Text fz="md" fw="bold">Pengalaman</Text>
<EditEditor <EditEditor
value={perbekelState.edit.form.pengalaman} value={perbekelState.edit.form.pengalaman}
onChange={(val) => perbekelState.edit.form.pengalaman = val} onChange={(val) => perbekelState.edit.form.pengalaman = val}
/> />
</Box> </Box>
{/* Pengalaman Organisasi */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Pengalaman Organisasi</Text> <Text fz="md" fw="bold">Pengalaman Organisasi</Text>
<EditEditor <EditEditor
value={perbekelState.edit.form.pengalamanOrganisasi} value={perbekelState.edit.form.pengalamanOrganisasi}
onChange={(val) => perbekelState.edit.form.pengalamanOrganisasi = val} onChange={(val) => perbekelState.edit.form.pengalamanOrganisasi = val}
/> />
</Box> </Box>
{/* Program Unggulan */}
<Box> <Box>
<Text fz={"md"} fw={"bold"}>Program Unggulan</Text> <Text fz="md" fw="bold">Program Unggulan</Text>
<EditEditor <EditEditor
value={perbekelState.edit.form.programUnggulan} value={perbekelState.edit.form.programUnggulan}
onChange={(val) => perbekelState.edit.form.programUnggulan = val} onChange={(val) => perbekelState.edit.form.programUnggulan = val}
/> />
</Box> </Box>
{/* Submit */}
<Group> <Group>
<Button <Button
onClick={handleSubmit}
bg={colors['blue-button']} bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || perbekelState.edit.loading}
disabled={!perbekelState.edit.form.biodata.trim()}
> >
Submit {isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting || perbekelState.edit.loading}>
Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Box>
</Box>
</Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>
); )
} }
export default ProfilePerbekel; export default ProfilePerbekel;

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Divider, Grid, GridCol, Image, Paper, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Center, Divider, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -12,98 +12,102 @@ function Page() {
const router = useRouter(); const router = useRouter();
const snap = useSnapshot(stateProfileDesa); const snap = useSnapshot(stateProfileDesa);
// Panggil load data sekali saat komponen mount // Load data saat mount
useEffect(() => { useEffect(() => {
stateProfileDesa.profilPerbekel.findUnique.load("edit"); stateProfileDesa.profilPerbekel.findUnique.load("edit");
}, []); }, []);
const perbekel = snap.profilPerbekel.findUnique.data; const perbekel = snap.profilPerbekel.findUnique.data;
if (!perbekel) {
return ( return (
<Paper bg={colors['white-1']} p={'md'}> <Stack align="center" justify="center" py="xl">
<Stack gap={"xs"}> <Skeleton radius="md" height={800} />
<Grid> </Stack>
);
}
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md">
{/* Header + tombol edit */}
<Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3}>Preview Profile PPID</Title> <Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button bg={colors['blue-button']} onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${snap.profilPerbekel.findUnique.data?.id}`)}> <Tooltip label="Edit Profil Perbekel" withArrow>
<IconEdit size={16} /> <Button
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${perbekel.id}`)}
>
Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
{perbekel && (
<Box> {/* Card Profil */}
<Paper p={"xl"} bg={colors['BG-trans']}> <Paper p="xl" bg={colors['white-1']} withBorder radius="md" shadow="xs">
<Box px={{ base: "md", md: 100 }}> <Box px={{ base: "sm", md: 100 }}>
<Grid> <Grid>
<GridCol span={{ base: 12, md: 12 }}> <GridCol span={12}>
<Center> <Center>
<Image src={"/darmasaba-icon.png"} w={{ base: 100, md: 150 }} alt='' /> <Image src="/darmasaba-icon.png" w={{ base: 100, md: 150 }} alt="Logo Desa" />
</Center> </Center>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 12 }}> <GridCol span={12}>
<Text ta={"center"} fz={{ base: "1.2rem", md: "1.8rem" }} fw={'bold'}>PROFIL PIMPINAN BADAN PUBLIK DESA DARMASABA </Text> <Text ta="center" fz={{ base: "1.2rem", md: "1.8rem" }} fw="bold" c={colors['blue-button']}>
Profil Pimpinan Badan Publik Desa Darmasaba
</Text>
</GridCol> </GridCol>
</Grid> </Grid>
</Box> </Box>
<Divider my={"md"} color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
{/* biodata perbekel */}
<Box px={{ base: 0, md: 50 }} pb={30}> <Stack gap={0} px={{ base: 0, md: 50 }} pb="xl">
<Box pb={20} px={{ base: 0, md: 50 }}>
<Paper bg={colors['BG-trans']} w={{ base: "100%", md: "100%" }}>
<Stack gap={0}>
<Center> <Center>
<Image <Image
pt={{ base: 0, md: 90 }} pt={{ base: 0, md: 60 }}
src={perbekel.image?.link || "/perbekel.png"} src={perbekel.image?.link || "/perbekel.png"}
w={{ base: 250, md: 350 }} w={{ base: 250, md: 350 }}
alt='Foto Profil PPID' alt="Foto Profil Perbekel"
onError={(e) => { radius="md"
e.currentTarget.src = "/perbekel.png"; onError={(e) => { e.currentTarget.src = "/perbekel.png"; }}
}}
/> />
</Center> </Center>
<Paper <Paper
bg={colors['blue-button']} bg={colors['blue-button']}
py={20} py="md"
px="sm"
radius="md"
className="glass3" className="glass3"
px={{ base: 10, md: 10 }} style={{ mt: -30, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
> >
<Text ta={"center"} c={colors['white-1']} fw={"bolder"} fz={{ base: "1.2rem", md: "1.6rem" }}> <Text ta="center" c={colors['white-1']} fw="bolder" fz={{ base: "1.2rem", md: "1.6rem" }}>
I.B. Surya Prabhawa Manuaba, S.H.,M.H.,NL.P. I.B. Surya Prabhawa Manuaba, S.H., M.H.
</Text> </Text>
</Paper> </Paper>
</Stack> </Stack>
</Paper>
</Box> {/* Biodata & Info */}
<Box pt={10}> <Box mt="lg">
<Box> <Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mb={4}>Biodata</Text>
<Text fz={{ base: "1.125rem", md: "1.6rem" }} fw={'bold'}>Biodata</Text> <Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: perbekel.biodata }} />
<Text fz={{ base: "1rem", md: "1.5rem" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: perbekel.biodata }} />
</Box> <Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Pengalaman</Text>
<Box> <Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: perbekel.pengalaman }} />
<Text fz={{ base: "1.125rem", md: "1.6rem" }} fw={'bold'}>Pengalaman</Text>
<Text fz={{ base: "1rem", md: "1.5rem" }} dangerouslySetInnerHTML={{ __html: perbekel.pengalaman }} /> <Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Pengalaman Organisasi</Text>
</Box> <Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: perbekel.pengalamanOrganisasi }} />
</Box>
<Box pb={30}> <Text fz={{ base: "1.125rem", md: "1.5rem" }} fw="bold" mt="md" mb={4}>Program Kerja Unggulan</Text>
<Text fz={{ base: "1.125rem", md: "1.6rem" }} fw={'bold'}>Pengalaman Organisasi</Text> <Text fz={{ base: "1rem", md: "1.4rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: perbekel.programUnggulan }} />
<Box px={20}>
<Text fz={{ base: "1rem", md: "1.5rem" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: perbekel.pengalamanOrganisasi }} />
</Box>
</Box>
<Box pb={20}>
<Text fz={{ base: "1.125rem", md: "1.6rem" }} fw={'bold'}>Program Kerja Unggulan</Text>
<Box px={20}>
<Text fz={{ base: "1rem", md: "1.5rem" }} ta={"justify"} dangerouslySetInnerHTML={{ __html: perbekel.programUnggulan }} />
</Box>
</Box>
</Box> </Box>
</Paper> </Paper>
</Box>
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@@ -91,7 +91,6 @@ function ListResponden({ search }: ListRespondenProps) {
<Table <Table
striped striped
highlightOnHover highlightOnHover
withTableBorder
withRowBorders withRowBorders
verticalSpacing="sm" verticalSpacing="sm"
> >

View File

@@ -73,8 +73,6 @@ function ListDaftarInformasi({ search }: { search: string }) {
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
<Table <Table
highlightOnHover highlightOnHover
withTableBorder
withColumnBorders
striped striped
stickyHeader stickyHeader
style={{ minWidth: '700px' }} style={{ minWidth: '700px' }}

View File

@@ -59,7 +59,7 @@ function ListResponden({ search }: ListRespondenProps) {
<Paper withBorder bg="white" p="lg" radius="md" shadow="sm"> <Paper withBorder bg="white" p="lg" radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Data Responden</Title> <Title order={3}>Data Responden</Title>
<Table striped withTableBorder withRowBorders> <Table striped withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ textAlign: 'center' }}>No</TableTh> <TableTh style={{ textAlign: 'center' }}>No</TableTh>
@@ -82,7 +82,7 @@ function ListResponden({ search }: ListRespondenProps) {
<Paper withBorder bg="white" p="lg" radius="md" shadow="sm"> <Paper withBorder bg="white" p="lg" radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Data Responden</Title> <Title order={3}>Data Responden</Title>
<Table striped withTableBorder withRowBorders> <Table striped withRowBorders>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '5%', textAlign: 'center' }}>No</TableTh> <TableTh style={{ width: '5%', textAlign: 'center' }}>No</TableTh>

View File

@@ -1,11 +1,53 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriBeritaFindMany() { async function kategoriBeritaFindMany(context: Context) {
const data = await prisma.kategoriBerita.findMany(); // Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.kategoriBerita.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.kategoriBerita.count({ where }),
]);
return { return {
success: true, success: true,
message: "Success get all kategori berita", message: "Berhasil ambil kategori berita dengan pagination",
data, data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data kategori berita",
}; };
} }
}
export default kategoriBeritaFindMany;

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
@@ -5,43 +6,44 @@ export default async function pelayananSuratKeteranganFindMany(context: Context)
const page = Number(context.query.page) || 1; const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10; const limit = Number(context.query.limit) || 10;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const search = (context.query.search as string) || '';
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
];
}
try { try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.pelayananSuratKeterangan.findMany({ prisma.pelayananSuratKeterangan.findMany({
where: { isActive: true }, where,
include: {
image: true,
image2: true,
},
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
prisma.pelayananSuratKeterangan.count({ prisma.pelayananSuratKeterangan.count({ where }),
where: { isActive: true }
})
]); ]);
const totalPages = Math.ceil(total / limit);
return { return {
success: true, success: true,
message: "Success fetch pelayanan surat keterangan with pagination", message: "Berhasil ambil pelayanan surat keterangan dengan pagination",
data, data,
page, page,
totalPages, limit,
total, total,
totalPages: Math.ceil(total / limit),
}; };
} catch (e) { } catch (e) {
console.error("Find many paginated error:", e); console.error("Error di findMany paginated:", e);
return { return {
success: false, success: false,
message: "Failed fetch pelayanan surat keterangan with pagination", message: "Gagal mengambil data pelayanan surat keterangan",
data: [],
page: 1,
totalPages: 1,
total: 0,
}; };
} }
} }

View File

@@ -1,43 +1,49 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function pelayananTelunjukSaktiDesaFindMany(context: Context) { export default async function pelayananTelunjukSaktiDesaFindMany(
context: Context
) {
const page = Number(context.query.page) || 1; const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10; const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || "";
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
try { try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.pelayananTelunjukSaktiDesa.findMany({ prisma.pelayananTelunjukSaktiDesa.findMany({
where: { isActive: true }, where,
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}), }),
prisma.pelayananTelunjukSaktiDesa.count({ prisma.pelayananTelunjukSaktiDesa.count({ where }),
where: { isActive: true }
})
]); ]);
const totalPages = Math.ceil(total / limit);
return { return {
success: true, success: true,
message: "Success fetch pelayanan telunjuk sakti desa with pagination", message: "Berhasil ambil pelayanan telunjuk sakti desa dengan pagination",
data, data,
page, page,
totalPages, limit,
total, total,
totalPages: Math.ceil(total / limit),
}; };
} catch (e) { } catch (e) {
console.error("Find many paginated error:", e); console.error("Error di findMany paginated:", e);
return { return {
success: false, success: false,
message: "Failed fetch pelayanan telunjuk sakti desa with pagination", message: "Gagal mengambil data pelayanan telunjuk sakti desa",
data: [],
page: 1,
totalPages: 1,
total: 0,
}; };
} }
} }

View File

@@ -1,46 +1,50 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function penghargaanFindMany(context: Context) { export default async function penghargaanFindMany(context: Context) {
const page = Number(context.query.page) || 1; const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10; const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || "";
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ deskripsi: { contains: search, mode: "insensitive" } },
];
}
try { try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.penghargaan.findMany({ prisma.penghargaan.findMany({
where: { isActive: true }, where,
include: {
image: true,
},
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}), }),
prisma.penghargaan.count({ prisma.penghargaan.count({ where }),
where: { isActive: true }
})
]); ]);
const totalPages = Math.ceil(total / limit);
return { return {
success: true, success: true,
message: "Success fetch penghargaan with pagination", message: "Berhasil ambil penghargaan dengan pagination",
data, data,
page, page,
totalPages, limit,
total, total,
totalPages: Math.ceil(total / limit),
}; };
} catch (e) { } catch (e) {
console.error("Find many paginated error:", e); console.error("Error di findMany paginated:", e);
return { return {
success: false, success: false,
message: "Failed fetch penghargaan with pagination", message: "Gagal mengambil data penghargaan",
data: [],
page: 1,
totalPages: 1,
total: 0,
}; };
} }
} }

View File

@@ -1,16 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kategoriPengumumanFindMany() { async function kategoriPengumumanFindMany(context: Context) {
const data = await prisma.categoryPengumuman.findMany({ const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || "";
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
try {
const [data, total] = await Promise.all([
prisma.categoryPengumuman.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { include: {
_count: { _count: {
select: { select: {
pengumumans: true pengumumans: true,
},
},
},
}),
prisma.categoryPengumuman.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil kategori potensi dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (error) {
console.error("Error di findMany paginated:", error);
return {
success: false,
message: "Gagal mengambil data kategori potensi",
};
} }
} }
}
});
return { data };
}
export default kategoriPengumumanFindMany export default kategoriPengumumanFindMany;

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
@@ -5,11 +6,23 @@ export default async function potensiDesaFindMany(context: Context) {
const page = Number(context.query.page) || 1; const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10; const limit = Number(context.query.limit) || 10;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const search = (context.query.search as string) || '';
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ kategori: { nama: { contains: search, mode: 'insensitive' } } },
{ deskripsi: { contains: search, mode: 'insensitive' } },
];
}
try { try {
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.potensiDesa.findMany({ prisma.potensiDesa.findMany({
where: { isActive: true }, where,
skip, skip,
take: limit, take: limit,
include: { include: {
@@ -23,14 +36,12 @@ export default async function potensiDesaFindMany(context: Context) {
}) })
]); ]);
const totalPages = Math.ceil(total / limit);
return { return {
success: true, success: true,
message: "Success fetch potensi desa with pagination", message: "Success fetch potensi desa with pagination",
data, data,
page, page,
totalPages, totalPages: Math.ceil(total / limit),
total, total,
}; };
} catch (e) { } catch (e) {

View File

@@ -1,11 +1,53 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriPotensiFindMany() { async function kategoriPotensiFindMany(context: Context) {
const data = await prisma.kategoriPotensi.findMany(); // Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ nama: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.kategoriPotensi.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.kategoriPotensi.count({ where }),
]);
return { return {
success: true, success: true,
message: "Success get all kategori potensi", message: "Berhasil ambil kategori potensi dengan pagination",
data, data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data kategori potensi",
}; };
} }
}
export default kategoriPotensiFindMany;