Compare commits

...

3 Commits

Author SHA1 Message Date
ccf39bc778 Penambahan fungsi search disetiap menu & submenu,
Menu Landing Page
Menu PPID
Menu Desa
2025-10-15 10:13:02 +08:00
3c21f7742c Yang sudh dikerjakan:
- Saat Mau minjam muncul modal data diri peminjam buku V
- Ada Status Peminjamannya ( status buku bisa engga otomatis dipinjemnya), kalau dikembalikan statusnya otomatis
)
Yang Mau Dikerjakan:
Cek fungsi menu yang kompleks
2025-10-14 10:38:55 +08:00
a158241c0b - QC User & Admin Menu Pendidikan V
- Fix SubMenu :
- Beasiswa Desa ( Baca Selengkapnya terdapatkan konten ) V
- Info Sekolah ( Kategori Menyesuaikan Dengan Datanya ) V
- Perpustakaan Digital (  V
- Kategori Menyesuaikan Dengan Datanya V
- Saat Mau minjam muncul modal data diri peminjam buku V
- Ada Status Peminjamannya V
)
2025-10-13 11:20:38 +08:00
52 changed files with 3062 additions and 300 deletions

View File

@@ -143,7 +143,7 @@ model MediaSosial {
isActive Boolean @default(true)
}
//========================================= PROFILE ========================================= //
//========================================= DESA ANTI KORUPSI ========================================= //
model DesaAntiKorupsi {
id String @id @default(cuid())
name String @unique
@@ -324,8 +324,8 @@ model PegawaiPPID {
model StrukturOrganisasiPPID {
id String @id @default(uuid())
posisiOrganisasiId String @db.VarChar(50)
pegawaiId String
hubunganOrganisasiId String
pegawaiId String
hubunganOrganisasiId String
posisiOrganisasi PosisiOrganisasiPPID @relation(fields: [posisiOrganisasiId], references: [id])
pegawai PegawaiPPID @relation(fields: [pegawaiId], references: [id])
createdAt DateTime @default(now())
@@ -1450,8 +1450,8 @@ model PegawaiBumDes {
model StrukturOrganisasiBumDes {
id String @id @default(uuid())
posisiOrganisasiId String @db.VarChar(50)
pegawaiId String
hubunganOrganisasiId String
pegawaiId String
hubunganOrganisasiId String
posisiOrganisasi PosisiOrganisasiBumDes @relation(fields: [posisiOrganisasiId], references: [id])
pegawai PegawaiBumDes @relation(fields: [pegawaiId], references: [id])
createdAt DateTime @default(now())
@@ -1820,7 +1820,7 @@ model KategoriKegiatan {
isActive Boolean @default(true)
KegiatanDesa KegiatanDesa[]
}
// ========================================= EDUKASI LINGKUNGAN ========================================= //
model TujuanEdukasiLingkungan {
id String @id @default(cuid())
@@ -2087,6 +2087,9 @@ model DataPerpustakaan {
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
// relasi baru ke peminjaman
peminjamanBuku PeminjamanBuku[]
}
model KategoriBuku {
@@ -2099,6 +2102,31 @@ model KategoriBuku {
DataPerpustakaan DataPerpustakaan[]
}
model PeminjamanBuku {
id String @id @default(cuid())
nama String
noTelp String
alamat String
bukuId String
tanggalPinjam DateTime @default(now())
batasKembali DateTime // tenggat waktu pengembalian
tanggalKembali DateTime? // diisi saat dikembalikan
status StatusPeminjaman @default(Dipinjam)
catatan String? // opsional, misal: kondisi buku
buku DataPerpustakaan @relation(fields: [bukuId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
}
enum StatusPeminjaman {
Dipinjam
Dikembalikan
Terlambat
Dibatalkan
}
// ========================================= USER ========================================= //
model User {

View File

@@ -95,6 +95,41 @@ const dataPerpustakaan = proxy({
}
},
},
findManyAll: {
data: null as
| Prisma.DataPerpustakaanGetPayload<{
include: {
image: true;
kategori: true;
};
}>[]
| null,
loading: false,
search: "",
load: async (search = "", kategori = "") => {
dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path
dataPerpustakaan.findMany.search = search;
try {
const query: any = {};
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan["findManyAll"].get({ query });
if (res.status === 200 && res.data?.success) {
dataPerpustakaan.findManyAll.data = res.data.data ?? [];
} else {
dataPerpustakaan.findManyAll.data = [];
}
} catch (err) {
console.error("Gagal fetch data perpustakaan paginated:", err);
dataPerpustakaan.findManyAll.data = [];
} finally {
dataPerpustakaan.findManyAll.loading = false;
}
},
},
findUnique: {
data: null as Prisma.DataPerpustakaanGetPayload<{
include: {
@@ -514,9 +549,312 @@ const kategoriBuku = proxy({
},
});
const templatePeminjamanBuku = z.object({
nama: z.string().min(1, "Nama harus diisi"),
noTelp: z.string().min(1, "No Telp harus diisi"),
alamat: z.string().min(1, "Alamat harus diisi"),
bukuId: z.string().min(1, "Buku ID harus diisi"),
tanggalPinjam: z.string().min(1, "Tanggal Pinjam harus diisi"),
batasKembali: z.string().min(1, "Batas Kembali harus diisi"),
tanggalKembali: z.string().min(1, "Tanggal Kembali harus diisi"),
catatan: z.string().min(1, "Catatan harus diisi")
});
const defaultPeminjamanBuku = {
nama: "",
noTelp: "",
alamat: "",
bukuId: "",
tanggalPinjam: "",
batasKembali: "",
tanggalKembali: "",
catatan: ""
};
interface FormEditData {
nama: string;
noTelp: string;
alamat: string;
bukuId: string;
buku?: {
id: string;
judul: string;
};
tanggalPinjam: string;
batasKembali: string;
tanggalKembali: string;
catatan: string;
status: 'Dipinjam' | 'Dikembalikan' | 'Terlambat' | 'Dibatalkan';
}
const editForm: FormEditData = {
nama: "",
noTelp: "",
alamat: "",
bukuId: "",
tanggalPinjam: "",
batasKembali: "",
tanggalKembali: "",
catatan: "",
status: "Dipinjam"
}
const peminjamanBuku = proxy({
create: {
form: { ...defaultPeminjamanBuku },
loading: false,
async create() {
const cek = templatePeminjamanBuku.safeParse(peminjamanBuku.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
peminjamanBuku.create.loading = true;
const res =
await ApiFetch.api.pendidikan.perpustakaandigital.peminjamanbuku[
"create"
].post(peminjamanBuku.create.form);
if (res.status === 200) {
peminjamanBuku.findMany.load();
return toast.success("Data Peminjaman Buku Berhasil Dibuat");
}
console.log(res);
return toast.error("failed create");
} catch (error) {
console.log(error);
return toast.error("failed create");
} finally {
peminjamanBuku.create.loading = false;
}
},
},
findMany: {
data: [] as Prisma.PeminjamanBukuGetPayload<{
include: {
buku: true;
};
}>[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
peminjamanBuku.findMany.loading = true; // ✅ Akses langsung via nama path
peminjamanBuku.findMany.page = page;
peminjamanBuku.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.peminjamanbuku["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
peminjamanBuku.findMany.data = res.data.data ?? [];
peminjamanBuku.findMany.totalPages = res.data.totalPages ?? 1;
} else {
peminjamanBuku.findMany.data = [];
peminjamanBuku.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data peminjaman buku paginated:", err);
peminjamanBuku.findMany.data = [];
peminjamanBuku.findMany.totalPages = 1;
} finally {
peminjamanBuku.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PeminjamanBukuGetPayload<{
include: {
buku: true;
};
}> | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${id}`
);
if (res.ok) {
const data = await res.json();
peminjamanBuku.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
peminjamanBuku.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
peminjamanBuku.findUnique.data = null;
}
},
},
delete: {
loading: false,
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
peminjamanBuku.delete.loading = true;
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(
result.message || "Data Peminjaman Buku berhasil dihapus"
);
await peminjamanBuku.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus Data Peminjaman Buku");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus Data Peminjaman Buku");
} finally {
peminjamanBuku.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...editForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${id}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
nama: data.nama,
noTelp: data.noTelp,
alamat: data.alamat,
bukuId: data.bukuId,
tanggalPinjam: data.tanggalPinjam,
batasKembali: data.batasKembali,
tanggalKembali: data.tanggalKembali,
catatan: data.catatan,
status: data.status
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading peminjaman buku:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templatePeminjamanBuku.safeParse(peminjamanBuku.update.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
peminjamanBuku.update.loading = true;
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: this.form.nama,
noTelp: this.form.noTelp,
alamat: this.form.alamat,
bukuId: this.form.bukuId,
tanggalPinjam: this.form.tanggalPinjam,
batasKembali: this.form.batasKembali,
tanggalKembali: this.form.tanggalKembali,
catatan: this.form.catatan,
status: this.form.status
}),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update data peminjaman buku");
await peminjamanBuku.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update data peminjaman buku");
}
} catch (error) {
console.error("Error updating data peminjaman buku:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update data peminjaman buku"
);
return false;
} finally {
peminjamanBuku.update.loading = false;
}
},
reset() {
peminjamanBuku.update.id = "";
peminjamanBuku.update.form = { ...editForm };
},
},
})
const perpustakaanDigitalState = proxy({
dataPerpustakaan,
kategoriBuku,
peminjamanBuku,
});
export default perpustakaanDigitalState;

View File

@@ -561,6 +561,45 @@ const pegawai = proxy({
}
},
},
findManyAll: {
data: null as
| Prisma.PegawaiPPIDGetPayload<{
include: {
image: true;
posisi: true;
};
}>[]
| null,
loading: false,
search: "",
load: async (search = "") => {
// Change to arrow function
pegawai.findManyAll.loading = true; // Use the full path to access the property
pegawai.findManyAll.search = search;
try {
const query: any = { search };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.strukturppid.pegawai[
"find-many-all"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
pegawai.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load pegawai:", res.data?.message);
pegawai.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading pegawai:", error);
pegawai.findManyAll.data = [];
} finally {
pegawai.findManyAll.loading = false;
}
},
},
findUnique: {
data: null as
| (Prisma.PegawaiPPIDGetPayload<{

View File

@@ -27,7 +27,7 @@ function PelayananPendudukNonPermanent() {
);
useShallowEffect(() => {
pelayananPendudukNonPermanen.findById.load('1');
pelayananPendudukNonPermanen.findById.load('edit');
}, []);
if (!pelayananPendudukNonPermanen.findById.data) {

View File

@@ -43,7 +43,7 @@ function PerizinanBerusaha() {
try {
setLoading(true);
// You should get the ID from your router query or params
const id = '1'; // Replace with actual ID or get from URL params
const id = 'edit'; // Replace with actual ID or get from URL params
await pelayananPerizinanBerusaha.findById.load(id);
} catch (err) {
setError('Gagal memuat data');

View File

@@ -89,26 +89,26 @@ function ListArtikelKesehatan({ search }: { search: string }) {
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Judul</TableTh>
<TableTh>Konten</TableTh>
<TableTh>Aksi</TableTh>
<TableTh style={{ minWidth: 200 }}>Judul</TableTh>
<TableTh style={{ minWidth: 200 }}>Konten</TableTh>
<TableTh style={{ minWidth: 200 }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<TableTd style={{ minWidth: 200 }}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.title}
</Text>
</TableTd>
<TableTd>
<TableTd style={{ minWidth: 200 }} >
<Text truncate fz="sm" c="dimmed" lineClamp={1}>
{item.content}
</Text>
</TableTd>
<TableTd>
<TableTd style={{ minWidth: 200 }}>
<Button
variant="light"
color="blue"

View File

@@ -223,7 +223,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
{/* Chart */}
<Box mt="lg" style={{ width: '100%', minWidth: 300, height: 420, minHeight: 300 }}>
<Paper bg={colors['white-1']} p={'md'}>
<Paper withBorder bg={colors['white-1']} p={'md'}>
<Title pb={10} order={4}>Grafik Hasil Kepuasan Masyarakat</Title>
{mounted && diseaseChartData.length > 0 ? (
<Center>

View File

@@ -111,9 +111,7 @@ function ListInfoWabahPenyakit({ search }: { search: string }) {
</TableTd>
<TableTd>
<Box w={200}>
<Text truncate fz="sm" c="dimmed">
{item.deskripsiSingkat}
</Text>
<Text truncate="end" fz="sm" c="dimmed" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsiSingkat }} />
</Box>
</TableTd>
<TableTd>

View File

@@ -65,7 +65,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip

View File

@@ -72,7 +72,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip

View File

@@ -79,7 +79,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>

View File

@@ -73,7 +73,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip

View File

@@ -4,7 +4,7 @@ import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { IconBook2, IconCategory } from '@tabler/icons-react';
import { IconBook2, IconCategory, IconUser } from '@tabler/icons-react';
function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -25,6 +25,13 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Atur kategori untuk buku digital",
},
{
label: "Peminjam",
value: "peminjam",
href: "/admin/pendidikan/perpustakaan-digital/peminjam",
icon: <IconUser size={18} stroke={1.8} />,
tooltip: "Data Peminjam Buku",
},
];
const currentTab = tabs.find(tab => tab.href === pathname);
@@ -65,7 +72,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip

View File

@@ -0,0 +1,254 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
export type Status = "Dipinjam" | "Dikembalikan" | "Terlambat" | "Dibatalkan";
function EditPeminjam() {
const stateEdit = useProxy(perpustakaanDigitalState.peminjamanBuku);
const router = useRouter();
const params = useParams();
const [formData, setFormData] = useState<{
nama: string;
noTelp: string;
alamat: string;
bukuId: string;
buku?: {
id: string;
judul: string;
};
tanggalPinjam: string;
batasKembali: string;
tanggalKembali: string;
status: Status;
catatan: string;
}>({
nama: '',
noTelp: '',
alamat: '',
bukuId: '',
tanggalPinjam: '',
batasKembali: '',
tanggalKembali: '',
status: 'Dipinjam', // Default status
catatan: '',
});
useShallowEffect(() => {
perpustakaanDigitalState.dataPerpustakaan.findManyAll.load()
})
useEffect(() => {
const loadPeminjam = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await stateEdit.update.load(id);
if (data) {
setFormData((prev) => ({
...prev,
nama: data.nama ?? prev.nama,
noTelp: data.noTelp ?? prev.noTelp,
alamat: data.alamat ?? prev.alamat,
bukuId: data.bukuId ?? prev.bukuId,
buku: data.buku ? {
id: data.buku.id,
judul: data.buku.judul
} : undefined,
tanggalPinjam: data.tanggalPinjam ?? prev.tanggalPinjam,
batasKembali: data.batasKembali ?? prev.batasKembali,
tanggalKembali: data.tanggalKembali ?? prev.tanggalKembali,
status: (data.status as Status) ?? prev.status,
catatan: data.catatan ?? prev.catatan,
}));
}
} catch (error) {
console.error("Error loading peminjam:", error);
toast.error("Gagal mengambil data peminjam");
}
};
loadPeminjam();
}, [params?.id]);
const handleChange = (field: string, value: string | Status) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
stateEdit.update.form = {
...stateEdit.update.form,
...formData,
};
await stateEdit.update.update();
toast.success("Peminjam berhasil diperbarui!");
router.push("/admin/pendidikan/perpustakaan-digital/peminjam");
} catch (error) {
console.error("Error updating peminjam:", error);
toast.error("Terjadi kesalahan saat memperbarui peminjam");
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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">
Edit Peminjam Buku
</Title>
</Group>
{/* Card */}
<Paper
w={{ base: '100%', md: '60%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
value={formData.nama}
onChange={(e) => handleChange('nama', e.target.value)}
label={<Text fw="bold" fz="sm">Nama Peminjam</Text>}
placeholder="Masukkan nama peminjam"
required
/>
<TextInput
value={formData.noTelp}
onChange={(e) => handleChange('noTelp', e.target.value)}
label={<Text fw="bold" fz="sm">No Telp Peminjam</Text>}
placeholder="Masukkan no telp peminjam"
required
/>
<TextInput
value={formData.alamat}
onChange={(e) => handleChange('alamat', e.target.value)}
label={<Text fw="bold" fz="sm">Alamat Peminjam</Text>}
placeholder="Masukkan alamat peminjam"
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>Buku</Text>
<Select
placeholder="Pilih buku"
data={perpustakaanDigitalState.dataPerpustakaan.findManyAll.data?.map(p => ({ value: p.id, label: p.judul })) || []}
value={formData.bukuId}
onChange={(value) => value && setFormData({ ...formData, bukuId: value })}
searchable
clearable
/>
</Box>
<DateInput
value={formData.tanggalPinjam}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.update.form.tanggalPinjam =
date ? new Date(date).toISOString() : '';
}}
label={<Text fw="bold" fz="sm">Tanggal Pinjam</Text>}
placeholder="Masukkan tanggal pinjam"
required
/>
<DateInput
value={formData.tanggalKembali}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.update.form.tanggalKembali =
date ? new Date(date).toISOString() : '';
}}
label={<Text fw="bold" fz="sm">Tanggal Kembali</Text>}
placeholder="Masukkan tanggal kembali"
required
/>
<DateInput
value={formData.batasKembali}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.update.form.batasKembali =
date ? new Date(date).toISOString() : '';
}}
label={<Text fw="bold" fz="sm">Batas Kembali</Text>}
placeholder="Masukkan batas kembali"
required
/>
<Select
value={formData.status}
onChange={(val) => handleChange('status', val as Status)}
label={<Text fw="bold" fz="sm">Status</Text>}
placeholder="Pilih status"
data={[
{ value: "Dipinjam", label: "Dipinjam" },
{ value: "Dikembalikan", label: "Dikembalikan" },
{ value: "Terlambat", label: "Terlambat" },
{ value: "Dibatalkan", label: "Dibatalkan" },
]}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>Catatan</Text>
<EditEditor
value={formData.catatan}
onChange={(htmlContent) => handleChange('catatan', 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>
</Paper>
</Box>
);
}
export default EditPeminjam;

View File

@@ -0,0 +1,237 @@
'use client';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function DetailDataPeminjaman() {
const stateDetail = useProxy(perpustakaanDigitalState.peminjamanBuku);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
stateDetail.findUnique.load(params?.id as string);
}, []);
const data = stateDetail.findUnique.data;
const handleHapus = () => {
if (selectedId) {
stateDetail.delete.delete(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push('/admin/pendidikan/perpustakaan-digital/peminjam');
}
};
const renderStatusBadge = (status: string) => {
const normalized = status?.toUpperCase();
switch (normalized) {
case 'DIPINJAM':
return <Badge color="blue" variant="light">Dipinjam</Badge>;
case 'DIKEMBALIKAN':
return <Badge color="green" variant="light">Dikembalikan</Badge>;
case 'TERLAMBAT':
return <Badge color="orange" variant="light">Terlambat</Badge>;
case 'DIBATALKAN':
return <Badge color="red" variant="light">Dibatalkan</Badge>;
default:
return <Badge color="gray" variant="light">Tidak diketahui</Badge>;
}
};
if (!data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<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 Data Peminjam Buku
</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.nama || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
No Telp
</Text>
<Text
fz="md"
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
>
{data.noTelp || '-'}
</Text>
</Box>
{/* 🔹 Editable Status */}
<Box>
<Group justify="space-between" align="end">
<Box>
<Text fz="lg" fw="bold">
Status
</Text>
{renderStatusBadge(data.status || '')}
</Box>
</Group>
</Box>
<Box>
<Text fz="lg" fw="bold">
Alamat
</Text>
<Text fz="md" c="dimmed">
{data.alamat || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Buku
</Text>
<Text fz="md" c="dimmed">
{data.buku?.judul || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Tanggal Pinjam
</Text>
<Text fz="md" c="dimmed">
{data.tanggalPinjam
? new Date(data.tanggalPinjam).toLocaleDateString('id-ID')
: '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Tanggal Kembali
</Text>
<Text fz="md" c="dimmed">
{data.tanggalKembali
? new Date(data.tanggalKembali).toLocaleDateString('id-ID')
: '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Batas Kembali
</Text>
<Text fz="md" c="dimmed">
{data.batasKembali
? new Date(data.batasKembali).toLocaleDateString('id-ID')
: '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">
Catatan
</Text>
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.catatan || '-' }}
/>
</Box>
<Group gap="sm" mt="sm">
<Tooltip label="Hapus Peminjam Buku" withArrow position="top">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
disabled={stateDetail.delete.loading}
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Peminjam Buku" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(`/admin/pendidikan/perpustakaan-digital/peminjam/${data.id}/edit`)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus data ini?"
/>
</Box>
);
}
export default DetailDataPeminjaman;

View File

@@ -0,0 +1,157 @@
'use client';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import perpustakaanDigitalState from '../../../_state/pendidikan/perpustakaan-digital';
function PeminjamBuku() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Peminjam Buku"
placeholder="Cari peminjam..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListPeminjamBuku search={search} />
</Box>
);
}
function ListPeminjamBuku({ search }: { search: string }) {
const statePeminjam = useProxy(perpustakaanDigitalState.peminjamanBuku);
const router = useRouter();
const { data, page, totalPages, loading, load } = statePeminjam.findMany;
useShallowEffect(() => {
load(page, 10, search);
}, [page, search]);
const filteredData = data || [];
// 🔹 Fungsi helper untuk badge status
const renderStatusBadge = (status: string) => {
const normalized = status?.toUpperCase();
switch (normalized) {
case 'DIPINJAM':
return <Badge color="blue" variant="light">Dipinjam</Badge>;
case 'DIKEMBALIKAN':
return <Badge color="green" variant="light">Dikembalikan</Badge>;
case 'TERLAMBAT':
return <Badge color="orange" variant="light">Terlambat</Badge>;
case 'DIBATALKAN':
return <Badge color="red" variant="light">Dibatalkan</Badge>;
default:
return <Badge color="gray" variant="light">Tidak diketahui</Badge>;
}
};
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Title order={4} mb="md">Daftar Peminjam Buku</Title>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '10%' }}>No</TableTh>
<TableTh style={{ width: '60%' }}>Nama Peminjam</TableTh>
<TableTh style={{ width: '15%' }}>Status</TableTh>
<TableTh style={{ width: '15%' }}>Detail</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item, index) => (
<TableTr key={item.id}>
<TableTd>{index + 1}</TableTd>
<TableTd>
<Text truncate fz="sm">{item.nama}</Text>
</TableTd>
<TableTd>
{renderStatusBadge(item.status)}
</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/pendidikan/perpustakaan-digital/peminjam/${item.id}`)
}
>
<IconDeviceImacCog size={20} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">
Tidak ada data Peminjam buku yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
{totalPages > 1 && (
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
)}
</Box>
);
}
export default PeminjamBuku;

View File

@@ -66,7 +66,11 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<Tooltip

View File

@@ -103,18 +103,7 @@ function ListPegawaiPPID({ search }: { search: string }) {
</TableTr>
</TableThead>
<TableTbody>
{(() => {
console.log('Rendering table with items:', stateOrganisasi.findMany.data);
return null;
})()}
{([...filteredData]
.sort((a, b) => {
if (a.isActive === b.isActive) {
return a.namaLengkap.localeCompare(b.namaLengkap); // kalau status sama, urut nama
}
return Number(b.isActive) - Number(a.isActive); // aktif duluan
}) // Aktif di atas
).map((item) => (
{filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Box w={150}>

View File

@@ -21,10 +21,10 @@ function ListStrukturOrganisasiPPID() {
const stateOrganisasi = useProxy(stateStrukturPPID.pegawai);
useEffect(() => {
stateOrganisasi.findMany.load();
stateOrganisasi.findManyAll.load();
}, []);
if (stateOrganisasi.findMany.loading) {
if (stateOrganisasi.findManyAll.loading) {
return (
<Center py={40}>
<Loader size="lg" />
@@ -32,7 +32,7 @@ function ListStrukturOrganisasiPPID() {
);
}
if (!stateOrganisasi.findMany.data || stateOrganisasi.findMany.data.length === 0) {
if (!stateOrganisasi.findManyAll.data || stateOrganisasi.findManyAll.data.length === 0) {
return (
<Stack align="center" py={60} gap="sm">
<IconUsers size={60} stroke={1.5} color="var(--mantine-color-gray-6)" />
@@ -43,7 +43,7 @@ function ListStrukturOrganisasiPPID() {
const posisiMap = new Map<string, any>();
const aktifPegawai = stateOrganisasi.findMany.data.filter(p => p.isActive);
const aktifPegawai = stateOrganisasi.findManyAll.data?.filter(p => p.isActive);
for (const pegawai of aktifPegawai) {
const posisiId = pegawai.posisi.id;

View File

@@ -377,22 +377,5 @@ export const navBar = [
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "User & Role",
name: "User & Role",
path: "",
children: [
{
id: "User",
name: "User",
path: "/admin/user&role/user"
},
{
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
]
},
}
]

View File

@@ -66,9 +66,6 @@ async function lembagaPendidikanFindMany(context: Context) {
})
]);
console.log('Fetched data count:', data.length);
console.log('Total count:', total);
return {
success: true,
message: "Success fetch lembaga pendidikan with pagination",

View File

@@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function dataPerpustakaanFindManyAll(context: Context) {
const search = (context.query.search as string) || "";
const isActiveParam = context.query.isActive;
// Buat where clause dinamis
const where: any = {};
if (isActiveParam !== undefined) {
where.isActive = isActiveParam === "true";
}
if (search) {
where.OR = [
{ judul: { contains: search, mode: "insensitive" } },
{ deskripsi: { contains: search, mode: "insensitive" } },
];
}
try {
const data = await prisma.dataPerpustakaan.findMany({
where,
include: {
kategori: true,
image: true,
},
orderBy: { createdAt: "desc" },
});
return {
success: true,
message: "Success fetch all data perpustakaan (non-paginated)",
total: data.length,
data,
};
} catch (error) {
console.error("Find many all error:", error);
return {
success: false,
message: "Failed fetch all data perpustakaan",
total: 0,
data: [],
};
}
}

View File

@@ -4,6 +4,7 @@ import dataPerpustakaanDelete from "./del";
import dataPerpustakaanFindMany from "./findMany";
import dataPerpustakaanFindUnique from "./findUnique";
import dataPerpustakaanUpdate from "./updt";
import dataPerpustakaanFindManyAll from "./findManyAll";
const DataPerpustakaan = new Elysia({
prefix: "/dataperpustakaan",
@@ -18,7 +19,7 @@ const DataPerpustakaan = new Elysia({
kategoriId: t.String(),
}),
})
.get("/findManyAll", dataPerpustakaanFindManyAll)
.get("/findMany", dataPerpustakaanFindMany)
.get("/:id", async (context) => {
const response = await dataPerpustakaanFindUnique(

View File

@@ -1,12 +1,14 @@
import Elysia from "elysia";
import DataPerpustakaan from "./data-perpustakaan";
import KategoriBuku from "./kategori-buku";
import PeminjamanBuku from "./peminjaman";
const PerpustakaanDigital = new Elysia({
prefix: "/perpustakaandigital",
tags: ["Pendidikan / Perpustakaan Digital"],
})
.use(DataPerpustakaan)
.use(KategoriBuku);
.use(KategoriBuku)
.use(PeminjamanBuku);
export default PerpustakaanDigital;

View File

@@ -0,0 +1,40 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormCreate = {
nama: string;
noTelp: string;
alamat: string;
bukuId: string;
tanggalPinjam: string;
batasKembali: string;
tanggalKembali?: string;
catatan?: string;
}
export default async function peminjamanBukuCreate(context: Context) {
const body = (await context.body) as FormCreate;
try {
const result = await prisma.peminjamanBuku.create({
data: {
nama: body.nama,
noTelp: body.noTelp,
alamat: body.alamat,
bukuId: body.bukuId,
tanggalPinjam: body.tanggalPinjam,
batasKembali: body.batasKembali,
tanggalKembali: body.tanggalKembali,
catatan: body.catatan,
},
});
return {
success: true,
message: "Berhasil membuat peminjaman buku",
data: result,
};
} catch (error) {
console.error("Error creating peminjaman buku:", error);
throw new Error("Gagal membuat peminjaman buku: " + (error as Error).message);
}
}

View File

@@ -0,0 +1,16 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function peminjamanBukuDelete(context: Context) {
const id = context.params.id as string;
await prisma.peminjamanBuku.delete({
where: { id },
});
return {
status: 200,
success: true,
message: "Success delete peminjaman buku",
};
}

View File

@@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function peminjamanBukuFindMany(context: Context) {
// 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 = {};
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ catatan: { contains: search, mode: 'insensitive' } },
{ nama: { contains: search, mode: 'insensitive' } },
{ buku: { judul: { contains: search, mode: 'insensitive' } } },
{ noTelp: { contains: search, mode: 'insensitive' } },
{ alamat: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.peminjamanBuku.findMany({
where,
include: {
buku: {
select: {
id: true,
judul: true,
kategoriId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
isActive: true,
imageId: true,
deskripsi: true
}
},
},
skip,
take: limit,
orderBy: { tanggalPinjam: 'desc' },
}),
prisma.peminjamanBuku.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data peminjaman buku dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di peminjamanBukuFindMany:", e);
return {
success: false,
message: "Gagal mengambil data peminjaman buku",
};
}
}
export default peminjamanBukuFindMany;

View File

@@ -0,0 +1,49 @@
import prisma from "@/lib/prisma";
export default async function peminjamanBukuFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return {
success: false,
message: "ID is required",
}
}
try {
if (typeof id !== 'string') {
return {
success: false,
message: "ID is required",
}
}
const data = await prisma.peminjamanBuku.findUnique({
where: { id },
include: {
buku: true,
},
});
if (!data) {
return {
success: false,
message: "Data not found",
}
}
return {
success: true,
message: "Success get data peminjaman buku",
data,
}
} catch (error) {
console.error("Find by ID error:", error);
return {
success: false,
message: "Gagal mengambil data: " + (error instanceof Error ? error.message : 'Unknown error'),
}
}
}

View File

@@ -0,0 +1,48 @@
import Elysia, { t } from "elysia";
import peminjamanBukuCreate from "./create";
import peminjamanBukuDelete from "./del";
import peminjamanBukuFindMany from "./findMany";
import peminjamanBukuFindUnique from "./findUnique";
import peminjamanBukuUpdate from "./updt";
const PeminjamanBuku = new Elysia({
prefix: "/peminjamanbuku",
tags: ["Pendidikan / Perpustakaan Digital / Peminjaman Buku"],
})
.post("/create", peminjamanBukuCreate, {
body: t.Object({
nama: t.String(),
noTelp: t.String(),
alamat: t.String(),
bukuId: t.String(),
tanggalPinjam: t.String(),
batasKembali: t.String(),
tanggalKembali: t.String(),
catatan: t.String()
}),
})
.get("/findMany", peminjamanBukuFindMany)
.get("/:id", async (context) => {
const response = await peminjamanBukuFindUnique(
new Request(context.request)
);
return response;
})
.put("/:id", peminjamanBukuUpdate, {
body: t.Object({
nama: t.String(),
noTelp: t.String(),
alamat: t.String(),
bukuId: t.String(),
tanggalPinjam: t.String(),
batasKembali: t.String(),
tanggalKembali: t.String(),
catatan: t.String(),
status: t.String()
}),
})
.delete("/del/:id", peminjamanBukuDelete);
export default PeminjamanBuku;

View File

@@ -0,0 +1,49 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormUpdate = {
nama: string;
noTelp: string;
alamat: string;
bukuId: string;
tanggalPinjam: string;
batasKembali: string;
tanggalKembali?: string;
catatan?: string;
status?: 'Dipinjam' | 'Dikembalikan' | 'Terlambat' | 'Dibatalkan';
};
export default async function dataPerpustakaanUpdate(context: Context) {
const body = (await context.body) as FormUpdate;
const id = context.params.id as string;
try {
const result = await prisma.peminjamanBuku.update({
where: { id },
data: {
nama: body.nama,
noTelp: body.noTelp,
alamat: body.alamat,
bukuId: body.bukuId,
tanggalPinjam: body.tanggalPinjam,
batasKembali: body.batasKembali,
tanggalKembali: body.tanggalKembali,
catatan: body.catatan,
status: body.status,
},
include: {
buku: true,
}
});
return {
success: true,
message: "Berhasil mengupdate data peminjaman buku",
data: result,
};
} catch (error) {
console.error("Error updating data peminjaman buku:", error);
throw new Error(
"Gagal mengupdate data peminjaman buku: " + (error as Error).message
);
}
}

View File

@@ -2,61 +2,67 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
// Di findMany.ts
export default async function pegawaiFindMany(context: Context) {
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 isActiveParam = context.query.isActive;
// where clause dinamis
const where: any = {};
if (isActiveParam !== undefined) {
where.isActive = isActiveParam === "true";
}
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ namaLengkap: { contains: search, mode: "insensitive" } },
{ alamat: { contains: search, mode: "insensitive" } },
{ posisi: { nama: { contains: search, mode: "insensitive" } } },
];
}
try {
const [data, total] = await Promise.all([
// Ambil semua data terlebih dahulu (tanpa pagination)
const [allData, total] = await Promise.all([
prisma.pegawaiPPID.findMany({
where,
include: {
posisi: true,
image: true,
},
skip,
take: limit,
orderBy: { posisi: { hierarki: "asc" } },
}),
prisma.pegawaiPPID.count({
where,
}),
prisma.pegawaiPPID.count({ where }),
]);
// Sort manual berdasarkan hierarki posisi
const sortedData = allData.sort((a, b) => {
// Sort berdasarkan hierarki terlebih dahulu
if (a.posisi.hierarki !== b.posisi.hierarki) {
return a.posisi.hierarki - b.posisi.hierarki;
}
// Jika hierarki sama, sort berdasarkan nama posisi
return a.posisi.nama.localeCompare(b.posisi.nama);
});
// Lakukan pagination manual setelah sorting
const paginatedData = sortedData.slice(skip, skip + limit);
const totalPages = Math.ceil(total / limit);
return {
success: true,
message: "Success fetch pegawai with pagination",
data,
message: "Success fetch pegawai with hierarchy order",
data: paginatedData,
page,
totalPages,
total,
};
} catch (e) {
console.error("Find many paginated error:", e);
} catch (error) {
console.error("Find many pegawai error:", error);
return {
success: false,
message: "Failed fetch pegawai with pagination",
message: "Failed fetch pegawai",
data: [],
page: 1,
totalPages: 1,

View File

@@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function pegawaiFindManyAll(context: Context) {
const search = (context.query.search as string) || "";
const isActiveParam = context.query.isActive;
// Buat where clause dinamis
const where: any = {};
if (isActiveParam !== undefined) {
where.isActive = isActiveParam === "true";
}
if (search) {
where.OR = [
{ namaLengkap: { contains: search, mode: "insensitive" } },
{ alamat: { contains: search, mode: "insensitive" } },
];
}
try {
const data = await prisma.pegawaiPPID.findMany({
where,
include: {
posisi: true,
image: true,
},
orderBy: { posisi: { hierarki: "asc" } },
});
return {
success: true,
message: "Success fetch all pegawai (non-paginated)",
total: data.length,
data,
};
} catch (error) {
console.error("Find many all error:", error);
return {
success: false,
message: "Failed fetch all pegawai",
total: 0,
data: [],
};
}
}

View File

@@ -5,6 +5,7 @@ import pegawaiCreate from "./create";
import pegawaiNonActive from "./nonActive";
import pegawaiUpdate from "./updt";
import pegawaiDelete from "./del";
import pegawaiFindManyAll from "./findManyAll";
const Pegawai = new Elysia({
@@ -15,6 +16,9 @@ const Pegawai = new Elysia({
// ✅ Find all
.get("/find-many", pegawaiFindMany)
// ✅ Find all (non-paginated)
.get("/find-many-all", pegawaiFindManyAll)
// ✅ Find by ID
.get("/:id", async (context) => {
const response = await pegawaiFindUnique(context);

View File

@@ -0,0 +1,538 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Context } from "elysia";
import prisma from "@/lib/prisma";
export default async function searchFindMany(context: Context) {
const { query, page = 1, limit = 10, type } = context.query as any;
// Convert to numbers
const pageNum = parseInt(String(page), 10) || 1;
const limitNum = parseInt(String(limit), 10) || 10;
const skip = (pageNum - 1) * limitNum;
if (!query || query.trim() === "") {
return { data: [], nextPage: null };
}
// 🔍 kalau type dikirim → cari spesifik modul
//========================================= MENU LANDING PAGE ========================================= //
//========================================= PROFILE ========================================= //
if (type === "pejabatdesa") {
const data = await prisma.pejabatDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return {
data: data.map((b) => ({ type: "pejabatdesa", ...b })),
nextPage: data.length < limitNum ? null : pageNum + 1,
};
}
if (type === "programinovasi") {
const data = await prisma.programInovasi.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "mediasosial") {
const data = await prisma.mediaSosial.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= DESA ANTI KORUPSI ========================================= //
if (type === "desaantikorupsi") {
const data = await prisma.desaAntiKorupsi.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= SDGS Desa ========================================= //
if (type === "sdgsdesa") {
const data = await prisma.sdgsDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= APBDes ========================================= //
if (type === "apbdes") {
const data = await prisma.aPBDes.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= PRESTASI DESA ========================================= //
if (type === "prestasidesa") {
const data = await prisma.prestasiDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= INDEKS KEPUASAAN MASYARAKAT ========================================= //
if (type === "responden") {
const data = await prisma.responden.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//========================================= MENU PPID ========================================= //
//========================================= STRUKTUR PPID ========================================= //
if (type === "strukturppid") {
const data = await prisma.strukturPPID.findMany({
where: {
PegawaiPPID: { namaLengkap: { contains: query, mode: "insensitive" } },
},
include: {
PosisiOrganisasiPPID: true,
PegawaiPPID: true,
},
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= VISI MISI PPID ========================================= //
if (type === "visimisippid") {
const data = await prisma.visiMisiPPID.findMany({
where: {
visi: { contains: query, mode: "insensitive" },
misi: { contains: query, mode: "insensitive" },
},
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//
// ========================================= DASAR HUKUM PPID ========================================= //
if (type === "dasarhukumppid") {
const data = await prisma.dasarHukumPPID.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= PROFILE PPID ========================================= //
if (type === "profileppid") {
const data = await prisma.profilePPID.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= DAFTAR INFORMASI PUBLIK ========================================= //
if (type === "daftarinformasipublik") {
const data = await prisma.daftarInformasiPublik.findMany({
where: { jenisInformasi: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//=========================================PERMOHONAN INFORMASI PUBLIK========================= //
if (type === "permohonaninformasipublik") {
const data = await prisma.permohonanInformasiPublik.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
//=========================================PERMOHONAN INFORMASI KEBERATAN PUBLIK========================= //
if (type === "permohonaninformasikeberatanpublik") {
const data = await prisma.formulirPermohonanKeberatan.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= IKM ========================================= //
if (type === "ikm") {
const data = await prisma.indeksKepuasanMasyarakat.findMany({
where: { label: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= MENU DESA ========================================= //
// ========================================= PROFILE DESA ========================================= //
if (type === "sejarahdesa") {
const data = await prisma.sejarahDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "visimisidesa") {
const data = await prisma.visiMisiDesa.findMany({
where: { visi: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "lambangdesa") {
const data = await prisma.lambangDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "maskotdesa") {
const data = await prisma.maskotDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "profilperbekel") {
const data = await prisma.profilPerbekel.findMany({
where: { biodata: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "perbekeldarmasaba") {
const data = await prisma.perbekelDariMasaKeMasa.findMany({
where: { nama: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= BERITA ========================================= //
if (type === "berita") {
const data = await prisma.berita.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "kategoriBerita") {
const data = await prisma.kategoriBerita.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= POTENSI DESA ========================================= //
if (type === "potensi") {
const data = await prisma.potensiDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= PENGUMUMAN ========================================= //
if (type === "pengumuman") {
const data = await prisma.pengumuman.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// ========================================= GALLERY ========================================= //
if (type === "galleryFoto") {
const data = await prisma.galleryFoto.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "galleryVideo") {
const data = await prisma.galleryVideo.findMany({
where: { name: { contains: query, mode: "insensitive" } },
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
// 🌍 GLOBAL SEARCH — cari di beberapa modul sekaligus
const [
pejabatdesa,
programinovasi,
mediasosial,
desaantikorupsi,
sdgsdesa,
apbdes,
prestasidesa,
responden,
strukturppid,
visimisippid,
dasarhukumppid,
profileppid,
daftarinformasipublik,
permohonaninformasipublik,
permohonaninformasikeberatanpublik,
ikm,
sejarahdesa,
visimisidesa,
lambangdesa,
maskotdesa,
profilperbekel,
perbekeldarmasaba,
berita,
kategoriBerita,
potensi,
pengumuman,
galleryFoto,
galleryVideo,
pelayananSuratKeterangan,
pelayananPerizinanBerusaha,
pelayananTelunjukSaktiDesa,
pelayananPendudukNonPermanent,
penghargaan
] = await Promise.all([
prisma.pejabatDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.programInovasi.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.mediaSosial.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.desaAntiKorupsi.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.sdgsDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.aPBDes.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.prestasiDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.responden.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
// ✅ FIXED
prisma.strukturPPID.findMany({
where: {
PegawaiPPID: { namaLengkap: { contains: query, mode: "insensitive" } },
},
include: { PegawaiPPID: true },
take: limitNum,
}),
prisma.visiMisiPPID.findMany({
where: {
OR: [
{ visi: { contains: query, mode: "insensitive" } },
{ misi: { contains: query, mode: "insensitive" } },
],
},
take: limitNum,
}),
prisma.dasarHukumPPID.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.profilePPID.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.daftarInformasiPublik.findMany({
where: { jenisInformasi: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.permohonanInformasiPublik.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.formulirPermohonanKeberatan.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.indeksKepuasanMasyarakat.findMany({
where: { label: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.sejarahDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.visiMisiDesa.findMany({
where: {
OR: [
{ visi: { contains: query, mode: "insensitive" } },
{ misi: { contains: query, mode: "insensitive" } },
],
},
take: limitNum,
}),
prisma.lambangDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.maskotDesa.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.profilPerbekel.findMany({
where: { biodata: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.perbekelDariMasaKeMasa.findMany({
where: { nama: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.berita.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.kategoriBerita.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.potensiDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.pengumuman.findMany({
where: { judul: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.galleryFoto.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.galleryVideo.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.pelayananSuratKeterangan.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.pelayananPerizinanBerusaha.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.pelayananTelunjukSaktiDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.pelayananPendudukNonPermanen.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
prisma.penghargaan.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: limitNum,
}),
]);
return {
data: [
...pejabatdesa.map((b) => ({ type: "pejabatdesa", ...b })),
...programinovasi.map((b) => ({ type: "programinovasi", ...b })),
...mediasosial.map((b) => ({ type: "mediaSosial", ...b })),
...desaantikorupsi.map((b) => ({ type: "desaantikorupsi", ...b })),
...sdgsdesa.map((b) => ({ type: "sdgsdesa", ...b })),
...apbdes.map((b) => ({ type: "apbdes", ...b })),
...prestasidesa.map((b) => ({ type: "prestasidesa", ...b })),
...responden.map((b) => ({ type: "responden", ...b })),
...strukturppid.map((b) => ({ type: "strukturppid", ...b })),
...visimisippid.map((b) => ({ type: "visimisippid", ...b })),
...dasarhukumppid.map((b) => ({ type: "dasarhukumppid", ...b })),
...profileppid.map((b) => ({ type: "profileppid", ...b })),
...daftarinformasipublik.map((b) => ({
type: "daftarinformasipublik",
...b,
})),
...permohonaninformasipublik.map((b) => ({
type: "permohonaninformasipublik",
...b,
})),
...permohonaninformasikeberatanpublik.map((b) => ({
type: "permohonaninformasikeberatanpublik",
...b,
})),
...ikm.map((b) => ({ type: "ikm", ...b })),
...sejarahdesa.map((b) => ({ type: "sejarahdesa", ...b })),
...visimisidesa.map((b) => ({ type: "visimisidesa", ...b })),
...lambangdesa.map((b) => ({ type: "lambangdesa", ...b })),
...maskotdesa.map((b) => ({ type: "maskotdesa", ...b })),
...profilperbekel.map((b) => ({ type: "profilperbekel", ...b })),
...perbekeldarmasaba.map((b) => ({ type: "perbekeldarmasaba", ...b })),
...berita.map((b) => ({ type: "berita", ...b })),
...kategoriBerita.map((b) => ({ type: "kategoriBerita", ...b })),
...potensi.map((b) => ({ type: "potensi", ...b })),
...pengumuman.map((b) => ({ type: "pengumuman", ...b })),
...galleryFoto.map((b) => ({ type: "galleryFoto", ...b })),
...galleryVideo.map((b) => ({ type: "galleryVideo", ...b })),
...pelayananSuratKeterangan.map((b) => ({ type: "pelayananSuratKeterangan", ...b })),
...pelayananPerizinanBerusaha.map((b) => ({ type: "pelayananPerizinanBerusaha", ...b })),
...pelayananTelunjukSaktiDesa.map((b) => ({ type: "pelayananTelunjukSaktiDesa", ...b })),
...pelayananPendudukNonPermanent.map((b) => ({ type: "pelayananPendudukNonPermanent", ...b })),
...penghargaan.map((b) => ({ type: "penghargaan", ...b })),
],
nextPage: null, // bisa dibuat lebih kompleks kalau perlu
};
}

View File

@@ -0,0 +1,10 @@
import Elysia from "elysia";
import searchFindMany from "./findMany";
const Search = new Elysia({
prefix: "/api/search",
tags: ["Search"],
})
.get("/findMany", searchFindMany);
export default Search;

View File

@@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { proxy, subscribe } from 'valtio';
import { debounce } from 'lodash';
import ApiFetch from '@/lib/api-fetch';
interface SearchResult {
type?: string; // optional biar gak error
id: string | number;
title?: string;
[key: string]: any;
}
const searchState = proxy({
query: '',
page: 1,
limit: 10,
type: '', // kosong = global search
results: [] as SearchResult[],
nextPage: null as number | null,
loading: false,
async fetch() {
if (!searchState.query) return;
searchState.loading = true;
try {
const res = await ApiFetch.api.search.findMany.get({
query: {
query: searchState.query,
page: searchState.page,
limit: searchState.limit,
type: searchState.type,
},
});
const data = (res.data?.data || []).map((item: any) => ({
type: item.type ?? 'unknown', // pastikan selalu ada type
...item,
}));
if (searchState.page === 1) {
searchState.results = data;
} else {
searchState.results.push(...data);
}
searchState.nextPage = res.data?.nextPage || null;
} catch (e) {
console.error('Search fetch error:', e);
} finally {
searchState.loading = false;
}
},
async next() {
if (!searchState.nextPage || searchState.loading) return;
searchState.page = searchState.nextPage;
await searchState.fetch();
},
});
// 🔁 Auto debounce search trigger
const debouncedFetch = debounce(() => {
if (!searchState.query) return;
searchState.page = 1;
searchState.fetch();
}, 500);
subscribe(searchState, () => {
debouncedFetch();
});
export default searchState;

View File

@@ -25,6 +25,7 @@ import LandingPage from "./_lib/landing_page";
import Pendidikan from "./_lib/pendidikan";
import User from "./_lib/user";
import Role from "./_lib/user/role";
import Search from "./_lib/search";
const ROOT = process.cwd();
@@ -95,6 +96,7 @@ const ApiServer = new Elysia()
.use(Pendidikan)
.use(User)
.use(Role)
.use(Search)
.onError(({ code }) => {
if (code === "NOT_FOUND") {

View File

@@ -16,7 +16,7 @@ function PelayananPendudukNonPermanent() {
const loadData = async () => {
try {
setLoading(true);
await state.pelayananPendudukNonPermanen.findById.load('1');
await state.pelayananPendudukNonPermanen.findById.load('edit');
} catch (error) {
console.error('Gagal memuat data:', error);
} finally {

View File

@@ -17,7 +17,7 @@ function PelayananPerizinanBerusaha() {
const loadData = async () => {
try {
setLoading(true);
await state.pelayananPerizinanBerusaha.findById.load('1')
await state.pelayananPerizinanBerusaha.findById.load('edit')
} catch (error) {
console.error('Gagal memuat data:', error);
} finally {

View File

@@ -77,9 +77,7 @@ function Page() {
fallbackSrc="https://placehold.co/800x400?text=Gambar+tidak+tersedia"
loading="lazy"
/>
<Text py="md" fz={{ base: "sm", md: "md" }} ta="justify" lh={1.8}>
{state.findUnique.data?.deskripsi || 'Belum ada deskripsi untuk potensi desa ini.'}
</Text>
<Text py="md" fz={{ base: "sm", md: "md" }} ta="justify" lh={1.8} dangerouslySetInnerHTML={{ __html: state.findUnique.data?.deskripsi || 'Belum ada deskripsi untuk potensi desa ini.' }} />
</Stack>
</Paper>
</Container>

View File

@@ -121,13 +121,13 @@ function Page() {
</Badge>
</Group>
<Text fz="sm" c="dimmed">
Diposting: 12 Februari 2025 · Dinas Kesehatan
Diposting: {v.createdAt.toLocaleDateString()}
</Text>
<Divider />
<Text fz="sm" lh={1.5}>
<Text fz="sm" lh={1.5} lineClamp={3} truncate="end">
{v.deskripsiSingkat}
</Text>
<Button variant="light" radius="md" size="md" onClick={() => router.push(`/admin/kesehatan/info-wabah-penyakit/${v.id}`)}>
<Button variant="light" radius="md" size="md" onClick={() => router.push(`/darmasaba/kesehatan/info-wabah-penyakit/${v.id}`)}>
Selengkapnya
</Button>
</Stack>

View File

@@ -97,18 +97,23 @@ function Page() {
shadow="sm"
withBorder
bg={colors['white-trans-1']}
style={{ transition: 'all 0.3s ease' }}
style={{
transition: 'all 0.3s ease',
transform: 'translateY(0)',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-5px)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<Stack align="center" gap="md">
<Center>
<Image
src={v.image.link}
alt={v.name}
w={160}
h={160}
fit="contain"
h={180}
w="100%"
radius="md"
loading="lazy"
fit="cover"
style={{ aspectRatio: '4/3' }}
/>
</Center>
<Stack gap={4} w="100%">
@@ -151,8 +156,11 @@ function Page() {
styles={{
control: {
border: `1px solid ${colors['blue-button']}`,
transition: 'all 0.3s ease',
'&:hover': { backgroundColor: colors['blue-button'], color: 'white' },
},
}}
/>
</Center>

View File

@@ -28,11 +28,31 @@ export default function Page() {
if (data.length === 0) {
return (
<Box py="xl" px={{ base: "md", md: 100 }}>
<Text fz="lg" fw="bold" c={colors["blue-button"]}>
Tidak ada posyandu yang ditemukan
</Text>
</Box>
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl">
<Box px={{ base: "md", md: 100 }}>
<BackButton />
<Flex mt="md" justify="space-between" align="center" wrap="wrap" gap="md">
<Text
ta="left"
fz={{ base: "1.8rem", md: "2.5rem" }}
c={colors["blue-button"]}
fw="bold"
>
Posyandu Desa Darmasaba
</Text>
<TextInput
placeholder="Cari posyandu berdasarkan nama..."
aria-label="Pencarian Posyandu"
radius="xl"
size="md"
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "35%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
</Box>
</Stack>
);
}

View File

@@ -1,5 +1,101 @@
/* eslint-disable react-hooks/exhaustive-deps */
// 'use client'
// import colors from '@/con/colors';
// import {
// ActionIcon,
// Box,
// Button,
// Container,
// Group,
// Paper,
// Stack,
// Text,
// VisuallyHidden
// } from '@mantine/core';
// import { IconArrowLeft } from '@tabler/icons-react';
// import { useRouter, useSearchParams } from 'next/navigation';
// import React, { useState } from 'react';
// type LayoutSekolahProps = {
// title?: string;
// jenjangPendidikanList?: string[];
// children: React.ReactNode;
// };
// export default function LayoutSekolah({
// title = 'Cari Informasi Sekolah',
// jenjangPendidikanList = ['Semua', 'TK', 'SD', 'SMP', 'SMA'],
// children,
// }: LayoutSekolahProps) {
// const router = useRouter();
// const searchParams = useSearchParams();
// const initialJenjangPendidikan = searchParams.get('jenjangPendidikan') || 'Semua';
// const [jenjangPendidikanAktif, setJenjangPendidikanAktif] = useState(initialJenjangPendidikan);
// // Cleanup timeout
// // Handle jenjang pendidikan click
// const handleJenjangPendidikanChange = (k: string) => {
// // arahkan langsung ke route jenjang pendidikan
// if (k.toLowerCase() === 'semua') {
// setJenjangPendidikanAktif(k);
// router.push(`/darmasaba/pendidikan/info-sekolah/semua`);
// } else {
// setJenjangPendidikanAktif(k);
// router.push(`/darmasaba/pendidikan/info-sekolah/${encodeURIComponent(k.toLowerCase())}`);
// }
// };
// return (
// <Box style={{ minHeight: '100vh', background: colors.Bg, paddingBottom: 48 }}>
// <Container size="xl" py={{ base: 'md', md: 'xl' }}>
// <Stack gap="lg">
// {/* Back Button */}
// <ActionIcon onClick={() => window.history.back()} variant="light" radius="md" size="lg">
// <IconArrowLeft size={20} />
// <VisuallyHidden>Kembali</VisuallyHidden>
// </ActionIcon>
// {/* Search & Filter */}
// <Paper radius="lg" p="xl" withBorder>
// <Stack gap="md">
// <Text ta="center" fw={800} fz={28}>{title}</Text>
// <Text ta="center" fz={"md"} c="black">
// Temukan data lengkap mengenai lembaga pendidikan, jumlah siswa terdaftar, dan tenaga pengajar berdasarkan jenjang pendidikan yang tersedia (TK, SD, SMP, SMA). Gunakan tombol di bawah untuk melihat detail sesuai kebutuhanmu.
// </Text>
// <Group justify="center" gap="xs" wrap="wrap">
// {jenjangPendidikanList.map((k) => {
// const aktif = k === jenjangPendidikanAktif;
// return (
// <Button
// key={k}
// onClick={() => handleJenjangPendidikanChange(k)}
// radius="xl"
// size="sm"
// variant={aktif ? 'filled' : 'light'}
// >
// {k}
// </Button>
// );
// })}
// </Group>
// </Stack>
// </Paper>
// {/* Slot konten */}
// {children}
// </Stack>
// </Container>
// </Box>
// );
// }
'use client'
import colors from '@/con/colors';
// pastikan path benar
import {
ActionIcon,
Box,
@@ -9,44 +105,48 @@ import {
Paper,
Stack,
Text,
VisuallyHidden
VisuallyHidden,
Loader,
} from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { useState } from 'react';
import { useSnapshot } from 'valtio';
import React, { useEffect, useState } from 'react';
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
type LayoutSekolahProps = {
title?: string;
jenjangPendidikanList?: string[];
children: React.ReactNode;
};
export default function LayoutSekolah({
title = 'Cari Informasi Sekolah',
jenjangPendidikanList = ['Semua', 'TK', 'SD', 'SMP', 'SMA'],
children,
}: LayoutSekolahProps) {
const router = useRouter();
const searchParams = useSearchParams();
const initialJenjangPendidikan = searchParams.get('jenjangPendidikan') || 'Semua';
const snap = useSnapshot(infoSekolahPaud.jenjangPendidikan);
const [jenjangPendidikanAktif, setJenjangPendidikanAktif] = useState(initialJenjangPendidikan);
const [jenjangPendidikanAktif, setJenjangPendidikanAktif] = useState<string>(
searchParams.get('jenjangPendidikan') || 'Semua'
);
// Cleanup timeout
// Load jenjang pendidikan dari backend
useEffect(() => {
if (!snap.findMany.data) infoSekolahPaud.jenjangPendidikan.findMany.load(1, 100);
}, []);
// Handle jenjang pendidikan click
const handleJenjangPendidikanChange = (k: string) => {
// arahkan langsung ke route jenjang pendidikan
if (k.toLowerCase() === 'semua') {
setJenjangPendidikanAktif(k);
router.push(`/darmasaba/pendidikan/info-sekolah/semua`);
} else {
setJenjangPendidikanAktif(k);
router.push(`/darmasaba/pendidikan/info-sekolah/${encodeURIComponent(k.toLowerCase())}`);
}
const handleJenjangPendidikanChange = (nama: string) => {
setJenjangPendidikanAktif(nama);
const path =
nama.toLowerCase() === 'semua'
? `/darmasaba/pendidikan/info-sekolah/semua`
: `/darmasaba/pendidikan/info-sekolah/${encodeURIComponent(nama.toLowerCase())}`;
router.push(path);
};
// List tab dari data state
const jenjangList = ['Semua', ...(snap.findMany.data?.map((v) => v.nama) || [])];
return (
<Box style={{ minHeight: '100vh', background: colors.Bg, paddingBottom: 48 }}>
@@ -61,31 +161,41 @@ export default function LayoutSekolah({
{/* Search & Filter */}
<Paper radius="lg" p="xl" withBorder>
<Stack gap="md">
<Text ta="center" fw={800} fz={28}>{title}</Text>
<Text ta="center" fz={"md"} c="black">
Temukan data lengkap mengenai lembaga pendidikan, jumlah siswa terdaftar, dan tenaga pengajar berdasarkan jenjang pendidikan yang tersedia (TK, SD, SMP, SMA). Gunakan tombol di bawah untuk melihat detail sesuai kebutuhanmu.
<Text ta="center" fw={800} fz={28}>
{title}
</Text>
<Group justify="center" gap="xs" wrap="wrap">
{jenjangPendidikanList.map((k) => {
const aktif = k === jenjangPendidikanAktif;
return (
<Button
key={k}
onClick={() => handleJenjangPendidikanChange(k)}
radius="xl"
size="sm"
variant={aktif ? 'filled' : 'light'}
>
{k}
</Button>
);
})}
</Group>
<Text ta="center" fz="md" c="black">
Temukan data lengkap mengenai lembaga pendidikan, jumlah siswa terdaftar, dan tenaga
pengajar berdasarkan jenjang pendidikan (TK, SD, SMP, SMA).
</Text>
{snap.findMany.loading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
<Group justify="center" gap="xs" wrap="wrap">
{jenjangList.map((k) => {
const aktif = k === jenjangPendidikanAktif;
return (
<Button
key={k}
onClick={() => handleJenjangPendidikanChange(k)}
radius="xl"
size="sm"
variant={aktif ? 'filled' : 'light'}
>
{k}
</Button>
);
})}
</Group>
)}
</Stack>
</Paper>
{/* Slot konten */}
{/* Konten anak */}
{children}
</Stack>
</Container>

View File

@@ -0,0 +1,126 @@
'use client';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Image,
Loader,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowLeft, IconBook2 } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import ModalPeminjaman from '../../_lib/modalPeminjaman';
export default function DetailBukuUser() {
const router = useRouter();
const params = useParams();
const stateDetail = useProxy(perpustakaanDigitalState.dataPerpustakaan);
const [opened, setOpened] = useState(false);
useShallowEffect(() => {
if (params?.id) stateDetail.findUnique.load(params.id as string);
}, [params?.id]);
const data = stateDetail.findUnique.data;
if (!data) {
return (
<Center h="70vh">
<Loader color={colors['blue-button']} size="lg" />
</Center>
);
}
return (
<Box bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }}>
{/* Tombol Kembali */}
<Button
variant="subtle"
leftSection={<IconArrowLeft size={20} color={colors['blue-button']} />}
onClick={() => router.back()}
mb="lg"
>
Kembali
</Button>
<Paper
withBorder
shadow="md"
radius="xl"
bg="white"
p={{ base: 'md', md: 'xl' }}
maw={800}
mx="auto"
>
<Stack gap="lg" align="center">
{/* Cover Buku */}
<Image
src={data.image?.link || '/placeholder-book.jpg'}
alt={data.judul}
w={220}
h={300}
fit="contain"
radius="md"
loading="lazy"
/>
{/* Judul & Kategori */}
<Stack gap={4} align="center">
<Title order={2} ta="center" c={colors['blue-button']}>
{data.judul}
</Title>
{data.kategori?.name && (
<Badge color="cyan" variant="light" size="md">
{data.kategori.name}
</Badge>
)}
</Stack>
{/* Deskripsi Buku */}
<Paper bg="#F9FAFF" p="md" radius="md" shadow="xs" w="100%">
<Text fw={600} mb={6} c={colors['blue-button']}>
Deskripsi Buku
</Text>
<Text
fz="sm"
lh={1.6}
ta="justify"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/>
</Paper>
{/* Tombol Pinjam */}
<Button
size="md"
radius="xl"
color="blue"
mt="sm"
leftSection={<IconBook2 size={20} />}
onClick={() => setOpened(true)}
>
Pinjam Buku Ini
</Button>
</Stack>
</Paper>
{/* Modal Peminjaman */}
<ModalPeminjaman
opened={opened}
onClose={() => setOpened(false)}
buku={data}
/>
</Box>
);
}

View File

@@ -1,42 +1,52 @@
'use client'
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import { ActionIcon, Box, Center, Group, Image, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, Tooltip, Badge } from '@mantine/core';
import { ActionIcon, Badge, Box, Button, Center, Group, Image, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconBook2, IconRefresh } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { useTransitionRouter } from 'next-view-transitions';
import { useSearchParams } from 'next/navigation';
import { useCallback, useState } from 'react';
import { useProxy } from 'valtio/utils';
function Content({ kategoriBuku }: { kategoriBuku: string }) {
const state = useProxy(perpustakaanDigitalState);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const searchParams = useSearchParams();
const searchQuery = searchParams.get('search') || '';
const router = useTransitionRouter()
const decodedKategoriBuku = decodeURIComponent(kategoriBuku);
const kategoriFilter = decodedKategoriBuku.toLowerCase() === 'semua' ? '' : decodedKategoriBuku;
const loadData = useCallback(async (searchQuery: string = '') => {
const loadData = useCallback(async (searchQuery: string = '', page: number = 1) => {
try {
setIsLoading(true);
await state.dataPerpustakaan.findMany.load(1, 100, searchQuery, kategoriFilter);
const currentKategoriFilter = decodedKategoriBuku.toLowerCase() === 'semua' ? '' : decodedKategoriBuku;
await state.dataPerpustakaan.findMany.load(page, 3, searchQuery, currentKategoriFilter);
setCurrentPage(page);
setTotalPages(state.dataPerpustakaan.findMany.totalPages);
} finally {
setIsLoading(false);
}
}, [kategoriFilter, state.dataPerpustakaan.findMany]);
}, [state.dataPerpustakaan.findMany, decodedKategoriBuku]);
useShallowEffect(() => {
loadData(searchQuery);
}, [searchQuery, loadData]);
}, [searchQuery, loadData, kategoriBuku]);
const handleRefresh = () => {
loadData();
};
if (isLoading || !state.dataPerpustakaan.findMany.load || !state.dataPerpustakaan.findMany.data) {
const handlePageChange = (newPage: number) => {
loadData(searchQuery, newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if ((isLoading && !state.dataPerpustakaan.findMany.data) || !state.dataPerpustakaan.findMany.load) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={50}>
@@ -57,18 +67,23 @@ function Content({ kategoriBuku }: { kategoriBuku: string }) {
Koleksi Buku
</Text>
</Group>
<Tooltip label="Muat ulang koleksi" withArrow>
<ActionIcon
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
onClick={handleRefresh}
loading={isLoading}
radius="xl"
size="lg"
>
<IconRefresh size={20} />
</ActionIcon>
</Tooltip>
<Group>
<Text size="sm" c="dimmed">
Halaman {currentPage} dari {totalPages}
</Text>
<Tooltip label="Muat ulang koleksi" withArrow>
<ActionIcon
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
onClick={handleRefresh}
loading={isLoading}
radius="xl"
size="lg"
>
<IconRefresh size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{!state.dataPerpustakaan.findMany.data || state.dataPerpustakaan.findMany.data.length === 0 ? (
@@ -132,30 +147,23 @@ function Content({ kategoriBuku }: { kategoriBuku: string }) {
</Badge>
)}
</Stack>
<Spoiler
maxHeight={80}
showLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Lihat deskripsi
</Text>
}
hideLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Sembunyikan deskripsi
</Text>
}
expanded={expandedId === v.id}
onExpandedChange={(isExpanded) => setExpandedId(isExpanded ? v.id : null)}
>
<Text
ta="justify"
fz="sm"
lh={1.5}
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
<Text
ta="justify"
fz="sm"
lh={1.5}
lineClamp={5}
truncate="end"
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
}}
/>
{/* 📗 Tombol Detail */}
<Button variant='light' color='blue' onClick={() => router.push(`/darmasaba/pendidikan/perpustakaan-digital/${v.kategori?.name}/${v.id}`)}>
Lihat Detail
</Button>
</Stack>
</Paper>
</motion.div>
@@ -163,6 +171,15 @@ function Content({ kategoriBuku }: { kategoriBuku: string }) {
</SimpleGrid>
)}
</Box>
<Center mt="lg">
<Pagination
value={currentPage}
onChange={handlePageChange}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}

View File

@@ -1,11 +1,24 @@
'use client'
import { useEffect, useState } from 'react';
import { ActionIcon, Box, Flex, Grid, GridCol, Stack, Tabs, TabsList, TabsTab, Text, TextInput } from '@mantine/core';
import { IconSearch, IconUser } from '@tabler/icons-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import BackButton from '../../../desa/layanan/_com/BackButto';
'use client';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import {
Box,
Grid,
GridCol,
Stack,
Tabs,
TabsList,
TabsTab,
Text,
TextInput
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useSnapshot } from 'valtio';
import BackButton from '../../../desa/layanan/_com/BackButto';
type LayoutBukuProps = {
placeholder?: string;
@@ -15,7 +28,7 @@ type LayoutBukuProps = {
children?: React.ReactNode;
};
function LayoutTabs({
export default function LayoutTabs({
placeholder = 'Cari buku digital...',
searchIcon = <IconSearch size={20} />,
children,
@@ -23,6 +36,7 @@ function LayoutTabs({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const snap = useSnapshot(perpustakaanDigitalState);
const activeTab = pathname.split('/').pop() || 'semua';
const initialSearch = searchParams.get('search') || '';
@@ -30,6 +44,11 @@ function LayoutTabs({
const [searchTimeout, setSearchTimeout] = useState<number | null>(null);
const [activeTabState, setActiveTabState] = useState(activeTab);
// 🟦 Ambil kategori buku saat mount
useEffect(() => {
perpustakaanDigitalState.kategoriBuku.findMany.load();
}, []);
useEffect(() => {
setActiveTabState(activeTab);
}, [activeTab]);
@@ -51,7 +70,9 @@ function LayoutTabs({
if (value) params.set('search', value);
router.push(
`/darmasaba/pendidikan/perpustakaan-digital/${activeTab}${params.toString() ? `?${params.toString()}` : ''}`
`/darmasaba/pendidikan/perpustakaan-digital/${activeTab}${
params.toString() ? `?${params.toString()}` : ''
}`
);
};
@@ -63,41 +84,40 @@ function LayoutTabs({
}
};
// 🟩 Tabs dinamis berdasarkan kategori dari state
const kategoriTabs =
snap.kategoriBuku.findMany.data?.map((item) => ({
label: item.name,
value: item.name.toLowerCase().replace(/\s+/g, '-'),
href: `/darmasaba/pendidikan/perpustakaan-digital/${encodeURIComponent(item.name.toLowerCase().replace(/\s+/g, '-'))}`,
})) ?? [];
const tabs = [
{ label: 'Semua', value: 'semua', href: '/darmasaba/pendidikan/perpustakaan-digital/semua' },
{ label: 'Dokumenter', value: 'dokumenter', href: '/darmasaba/pendidikan/perpustakaan-digital/dokumenter' },
{ label: 'Sayuran', value: 'sayuran', href: '/darmasaba/pendidikan/perpustakaan-digital/sayuran' },
{ label: 'Dongeng', value: 'dongeng', href: '/darmasaba/pendidikan/perpustakaan-digital/dongeng' },
...kategoriTabs,
];
const handleTabChange = (value: string | null) => {
if (!value) return;
const params = new URLSearchParams(searchParams.toString());
router.push(`/darmasaba/pendidikan/perpustakaan-digital/${value}${params.toString() ? `?${params.toString()}` : ''}`);
router.push(
`/darmasaba/pendidikan/perpustakaan-digital/${value}${
params.toString() ? `?${params.toString()}` : ''
}`
);
};
return (
<Stack pos="relative" bg="var(--mantine-color-gray-0)" py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }}>
<Flex justify="space-between" align="center">
<BackButton />
<ActionIcon
variant="light"
component={Link}
href="/login"
radius="xl"
size="lg"
aria-label="Masuk ke akun"
>
<IconUser size={26} stroke={1.5} />
</ActionIcon>
</Flex>
</Box>
<Box pb={20}>
<Text ta="center" fz={{ base: '1.6rem', md: '2.4rem' }} fw={700} c={colors['blue-button']}>
Perpustakaan Digital Darmasaba
</Text>
<Tabs color="blue" variant="pills" value={activeTabState} onChange={handleTabChange}>
<Box px={{ base: 'md', md: 100 }} py="md" bg="var(--mantine-color-gray-1)" style={{ borderRadius: 16 }}>
<Grid align="center" gutter="md">
@@ -116,6 +136,7 @@ function LayoutTabs({
))}
</TabsList>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<TextInput
radius="xl"
@@ -130,11 +151,10 @@ function LayoutTabs({
</GridCol>
</Grid>
</Box>
{children}
</Tabs>
</Box>
</Stack>
);
}
export default LayoutTabs;

View File

@@ -0,0 +1,220 @@
'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Divider,
Group,
Image,
Modal,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import {
IconArrowRight,
IconBook2,
IconUser
} from '@tabler/icons-react';
import { useEffect } from 'react';
import { toast } from 'react-toastify';
import { useSnapshot } from 'valtio';
export interface ModalPeminjamanProps {
opened: boolean;
onClose: () => void;
buku: {
id: string;
judul: string;
deskripsi?: string;
image?: { link?: string };
kategori?: { name?: string };
} | null;
}
export default function ModalPeminjaman({
opened,
onClose,
buku,
}: ModalPeminjamanProps) {
const snap = useSnapshot(perpustakaanDigitalState.peminjamanBuku);
// reset form setiap modal dibuka
useEffect(() => {
if (opened && buku) {
perpustakaanDigitalState.peminjamanBuku.create.form = {
...perpustakaanDigitalState.peminjamanBuku.create.form,
bukuId: buku.id,
nama: '',
noTelp: '',
alamat: '',
tanggalPinjam: '',
batasKembali: '',
tanggalKembali: '',
catatan: '',
};
}
}, [opened, buku]);
const handleSubmit = async () => {
if (!buku) return toast.error('Data buku tidak ditemukan');
await perpustakaanDigitalState.peminjamanBuku.create.create();
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
radius="xl"
title={<Text fw={700}>Formulir Peminjaman Buku</Text>}
>
{buku ? (
<Stack>
{/* --- Info Buku --- */}
<Group align="flex-start">
<Image
src={buku.image?.link || '/placeholder-book.jpg'}
alt={buku.judul}
w={100}
radius="md"
/>
<Stack gap={4}>
<Group>
<IconBook2 size={18} color={colors['blue-button']} />
<Text fw={700}>{buku.judul}</Text>
</Group>
{buku.kategori?.name && (
<Badge color="cyan" variant="light">
{buku.kategori.name}
</Badge>
)}
<Text fz="sm" c="dimmed" lineClamp={3} dangerouslySetInnerHTML={{ __html: buku.deskripsi || 'Tidak ada deskripsi' }} />
</Stack>
</Group>
<Divider my="sm" />
{/* --- Form Input --- */}
<TextInput
label="Nama Peminjam"
placeholder="Masukkan nama lengkap"
leftSection={<IconUser size={16} />}
value={snap.create.form.nama}
onChange={(e) =>
(perpustakaanDigitalState.peminjamanBuku.create.form.nama = e.currentTarget.value)
}
required
/>
<TextInput
label="No Telp"
placeholder="Masukkan no telp"
leftSection={<IconUser size={16} />}
value={snap.create.form.noTelp}
onChange={(e) =>
(perpustakaanDigitalState.peminjamanBuku.create.form.noTelp = e.currentTarget.value)
}
required
/>
<TextInput
label="Alamat"
placeholder="Masukkan alamat"
leftSection={<IconUser size={16} />}
value={snap.create.form.alamat}
onChange={(e) =>
(perpustakaanDigitalState.peminjamanBuku.create.form.alamat = e.currentTarget.value)
}
required
/>
<DateInput
label="Tanggal Pinjam"
placeholder="Pilih tanggal pinjam"
value={
snap.create.form.tanggalPinjam
? new Date(snap.create.form.tanggalPinjam)
: null
}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.create.form.tanggalPinjam =
date ? new Date(date).toISOString() : '';
}}
required
/>
<Box>
<Text>Catatan</Text>
<CreateEditor
value={snap.create.form.catatan}
onChange={(e) =>
(perpustakaanDigitalState.peminjamanBuku.create.form.catatan = e)
}
/>
</Box>
<DateInput
label="Tanggal Kembali"
placeholder="Pilih tanggal kembali"
value={
snap.create.form.tanggalKembali
? new Date(snap.create.form.tanggalKembali)
: null
}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.create.form.tanggalKembali =
date ? new Date(date).toISOString() : '';
}}
required
/>
<DateInput
label="Batas Pengembalian"
placeholder="Pilih tanggal kembali"
value={
snap.create.form.batasKembali
? new Date(snap.create.form.batasKembali)
: null
}
onChange={(date) => {
perpustakaanDigitalState.peminjamanBuku.create.form.batasKembali =
date ? new Date(date).toISOString() : '';
}}
required
/>
<Button
onClick={handleSubmit}
loading={snap.create.loading}
disabled={
!snap.create.form.nama ||
!snap.create.form.tanggalPinjam ||
!snap.create.form.batasKembali ||
!snap.create.form.tanggalKembali
}
rightSection={<IconArrowRight size={16} />}
radius="xl"
>
Pinjam Buku
</Button>
</Stack>
) : (
<Text c="dimmed" ta="center">
Tidak ada data buku yang dipilih
</Text>
)}
</Modal>
);
}

View File

@@ -1,45 +1,89 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import { Badge, Box, Center, Group, Image, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { motion } from 'framer-motion';
import { useCallback, useState } from 'react';
import { useProxy } from 'valtio/utils';
import {
Badge,
Box,
Button,
Center,
Group,
Image,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Tooltip
} from '@mantine/core';
import { IconBook2, IconInfoCircle } from '@tabler/icons-react';
import { Pagination } from '@mantine/core';
import { motion } from 'framer-motion';
type ContentProps = {
searchQuery: string;
};
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import ModalPeminjaman from '../_lib/modalPeminjaman';
import { useTransitionRouter } from 'next-view-transitions';
function Content({ searchQuery }: ContentProps) {
export default function Content() {
const state = useProxy(perpustakaanDigitalState);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useTransitionRouter()
const [opened, setOpened] = useState(false);
const searchParams = useSearchParams();
const searchQuery = searchParams.get('search') || '';
const [currentPage, setCurrentPage] = useState(1);
const { data: books = [], loading, totalPages } = state.dataPerpustakaan.findMany;
const loadData = useCallback(
async (query: string = '') => {
const [selectedBook, setSelectedBook] = useState<{
id: string;
judul: string;
deskripsi?: string;
image?: { link?: string };
kategori?: { name?: string };
} | null>(null);
// Handle data loading and search
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const loadData = async () => {
if (!isMounted) return;
try {
setIsLoading(true);
await state.dataPerpustakaan.findMany.load(1, 100, query, '');
await state.dataPerpustakaan.findMany.load(
currentPage,
10,
searchQuery,
''
);
} catch (error) {
console.error('Gagal memuat data:', error);
} finally {
setIsLoading(false);
if (!controller.signal.aborted) {
console.error('Gagal memuat data:', error);
}
}
},
[state.dataPerpustakaan.findMany]
);
};
useShallowEffect(() => {
loadData(searchQuery);
}, [searchQuery, loadData]);
const timer = setTimeout(loadData, 300);
return () => {
isMounted = false;
controller.abort();
clearTimeout(timer);
};
}, [searchQuery, currentPage]);
if (
isLoading ||
!state.dataPerpustakaan.findMany.load ||
!state.dataPerpustakaan.findMany.data
) {
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Loading state
if (loading || !books) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={50}>
@@ -53,6 +97,7 @@ function Content({ searchQuery }: ContentProps) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={20}>
{/* 🔹 Header */}
<Group justify="space-between" mb="lg">
<Group gap="xs">
<IconBook2 size={28} color={colors['blue-button']} />
@@ -65,101 +110,152 @@ function Content({ searchQuery }: ContentProps) {
</Tooltip>
</Group>
{!state.dataPerpustakaan.findMany.data ||
state.dataPerpustakaan.findMany.data.length === 0 ? (
{/* 📚 Empty State */}
{books.length === 0 ? (
<Center py="xl">
<Stack gap="xs" align="center">
<Image loading="lazy" src="/empty-books.svg" alt="Kosong" w={140} h="auto" />
<Text c="dimmed" fz="sm">Belum ada buku yang tersedia</Text>
<Image
loading="lazy"
src="/empty-books.svg"
alt="Kosong"
w={140}
h="auto"
/>
<Text c="dimmed" fz="sm">
Belum ada buku yang tersedia
</Text>
</Stack>
</Center>
) : (
// 📘 Daftar Buku
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="lg"
verticalSpacing="lg"
style={{ alignItems: 'stretch' }}
>
{state.dataPerpustakaan.findMany.data?.map((v, k) => (
{books.map((v) => (
<motion.div
key={k}
key={v.id}
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
style={{ width: '100%', height: '100%' }}
style={{ height: '100%' }}
>
<Paper
p="lg"
<Paper
p="lg"
radius="2xl"
shadow="md"
bg="white"
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between'
justifyContent: 'space-between',
height: '100%',
minHeight: 440,
}}
>
<Stack gap="md" style={{ flex: 1 }}>
<Stack gap="md" style={{ flexGrow: 1 }}>
{/* 🖼 Gambar Buku */}
<Center>
<Image
src={v.image?.link}
alt={v.judul}
<Image
src={v.image?.link}
alt={v.judul}
h={180}
w="auto"
fit="contain"
fallbackSrc="/placeholder-book.jpg"
radius="md"
fallbackSrc="/placeholder-book.jpg"
loading="lazy"
/>
</Center>
<Stack gap={4} align="center">
<Text
c={colors["blue-button"]}
ta="center"
fw={700}
fz={{ base: "md", md: "lg" }}
{/* 📖 Judul & Kategori */}
<Stack gap={4} align="center" px="sm">
<Text
c={colors['blue-button']}
ta="center"
fw={700}
fz={{ base: 'md', md: 'lg' }}
lineClamp={2}
>
{v.judul}
</Text>
{v.kategori && (
<Badge color="cyan" radius="sm" variant="light" size="sm">
<Badge
color="cyan"
radius="sm"
variant="light"
size="sm"
>
{v.kategori.name}
</Badge>
)}
</Stack>
<Spoiler
maxHeight={80}
showLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Lihat deskripsi
</Text>
}
hideLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Sembunyikan deskripsi
</Text>
}
expanded={expandedId === v.id}
onExpandedChange={(isExpanded) => setExpandedId(isExpanded ? v.id : null)}
>
<Text
ta="justify"
fz="sm"
lh={1.5}
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
{/* 📝 Deskripsi */}
<Text
ta="justify"
fz="sm"
lh={1.5}
lineClamp={5}
truncate="end"
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
}}
/>
</Stack>
{/* 📗 Tombol Detail */}
<Button variant='light' color='blue' onClick={() => router.push(`/darmasaba/pendidikan/perpustakaan-digital/${v.kategori?.name}/${v.id}`)}>
Lihat Detail
</Button>
{/* 📗 Tombol Peminjaman */}
<Button
mt="md"
variant="outline"
color="blue"
radius="xl"
size="md"
fullWidth
leftSection={<IconBook2 size={20} />}
onClick={() => {
setSelectedBook(v);
setOpened(true);
}}
>
Peminjaman
</Button>
</Paper>
</motion.div>
))}
</SimpleGrid>
)}
<Center mt="lg">
<Pagination
value={currentPage}
onChange={handlePageChange}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Box>
{/* 🔸 Modal Peminjaman */}
<ModalPeminjaman
opened={opened}
onClose={() => {
setOpened(false);
setSelectedBook(null);
}}
buku={selectedBook}
/>
</Stack>
);
}
export default Content;

View File

@@ -1,13 +1,13 @@
// src/app/darmasaba/(pages)/desa/berita/[kategori]/page.tsx
import { Suspense } from "react";
import Content from "./content";
import Content from "../[kategoriBuku]/content";
export default async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Content searchQuery="" />
<Content kategoriBuku="semua" />
</Suspense>
);
}

View File

@@ -1,6 +1,6 @@
import stateNav from "@/state/state-nav";
import { Container, Stack, TextInput, Tooltip } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { Container, Stack, Tooltip } from "@mantine/core";
import GlobalSearch from "./globalSearch";
export function NavbarSearch() {
return (
@@ -12,14 +12,7 @@ export function NavbarSearch() {
>
<Stack pt="xl">
<Tooltip label="Type to search across the site" position="bottom-start" withArrow>
<TextInput
autoFocus
size="lg"
variant="filled"
radius="xl"
placeholder="Search anything..."
leftSection={<IconSearch size={20} />}
/>
<GlobalSearch />
</Tooltip>
</Stack>
</Container>

View File

@@ -0,0 +1,97 @@
'use client';
import { TextInput, Loader, Stack, Box, Text } from '@mantine/core';
import { useSnapshot } from 'valtio';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import searchState from '@/app/api/[[...slugs]]/_lib/search/searchState';
// Mapping type ke URL
const getDetailUrl = (item: { type?: string; id: string | number; [key: string]: unknown }) => {
const { type, id, kategori } = item;
const typeUrlMap: Record<string, string> = {
programinovasi: `/darmasaba/program-inovasi/${id}`,
desaantikorupsi: '/darmasaba/desa-anti-korupsi',
sdgsdesa: '/darmasaba/sdgs-desa',
apbdes: '/darmasaba/apbdes',
prestasidesa: '/darmasaba/prestasi-desa',
pejabatdesa: '/darmasaba/profile/pejabat-desa',
strukturppid: '/darmasaba/ppid/struktur-ppid',
visimisippid: '/darmasaba/ppid/visi-misi',
dasarhukumppid: '/darmasaba/ppid/dasar-hukum',
profileppid: '/darmasaba/ppid/profile',
daftarinformasipublik: '/darmasaba/ppid/daftar-informasi-publik',
perbekeldarmasaba: '/darmasaba/desa/profile',
berita: `/darmasaba/desa/berita/${kategori}/${id}`,
pengumuman: `/darmasaba/desa/pengumuman/${kategori}/${id}`,
sejarahdesa: '/darmasaba/desa/profile',
visimisidesa: '/darmasaba/desa/profile',
lambangdesa: '/darmasaba/desa/profile',
maskotdesa: '/darmasaba/desa/profile',
profilperbekel: '/darmasaba/desa/profile',
potensi: '/darmasaba/desa/potensi-desa',
galleryFoto: '/darmasaba/desa/gallery/foto',
galleryVideo: '/darmasaba/desa/gallery/video',
pelayananSuratKeterangan: '/darmasaba/desa/layanan',
pelayananPerizinanBerusaha: '/darmasaba/desa/layanan',
pelayananTelunjukSaktiDesa: '/darmasaba/desa/layanan',
pelayananPendudukNonPermanent: '/darmasaba/desa/layanan',
penghargaan: '/darmasaba/desa/penghargaan',
};
return type ? typeUrlMap[type] || '/darmasaba' : '/darmasaba';
};
export default function GlobalSearch() {
const snap = useSnapshot(searchState);
const router = useRouter();
// Infinite scroll listener
useEffect(() => {
const handleScroll = () => {
const bottom =
window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (bottom && !snap.loading) searchState.next();
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [snap.loading]);
return (
<Stack>
<TextInput
placeholder="Cari apapun..."
value={snap.query}
onChange={(e) => (searchState.query = e.currentTarget.value)}
/>
{snap.results.map((item, i) => (
<Box
key={i}
p="sm"
style={{
borderBottom: '1px solid #eee',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onClick={() => {
const url = getDetailUrl(item);
router.push(url);
}}
>
<Text size="sm" fw={500}>
{item.judul || item.namaPasar || item.nama || item.name}
</Text>
<Text size="xs" c="dimmed">
dari modul: {item.type}
</Text>
</Box>
))}
{snap.loading && <Loader size="sm" />}
</Stack>
);
}