Compare commits

..

12 Commits

388 changed files with 32187 additions and 19166 deletions

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ next-env.d.ts
.env.* .env.*
*.tar.gz

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,11 +3,9 @@
"version": "0.1.5", "version": "0.1.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "bun --bun next dev",
"build": "next build", "build": "bun --bun next build",
"start": "next start", "start": "bun --bun next start"
"lint": "next lint",
"prisma:seed": "bun run prisma/seed.ts"
}, },
"prisma": { "prisma": {
"seed": "bun run prisma/seed.ts" "seed": "bun run prisma/seed.ts"
@@ -57,6 +55,8 @@
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.23.5", "framer-motion": "^12.23.5",
"get-port": "^7.1.0", "get-port": "^7.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.0",
"jotai": "^2.12.3", "jotai": "^2.12.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -64,7 +64,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"motion": "^12.4.1", "motion": "^12.4.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.1.6", "next": "^15.5.2",
"next-view-transitions": "^0.3.4", "next-view-transitions": "^0.3.4",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
@@ -73,6 +73,7 @@
"prisma": "^6.3.1", "prisma": "^6.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-international-phone": "^4.6.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-simple-toasts": "^6.1.0", "react-simple-toasts": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",

View File

@@ -1,6 +0,0 @@
[
{
"id": "cmdpm429r0000vnndkcwslt0h",
"name": "warga"
}
]

View File

@@ -0,0 +1,29 @@
[
{
"id": "1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"permissions": ["manage_users", "manage_content", "view_reports"],
"isActive": true,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"permissions": ["manage_health_data", "view_reports"],
"isActive": true,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "3",
"name": "ADMIN SEKOLAH",
"description": "Administrator Sekolah",
"permissions": ["manage_school_data", "view_reports"],
"isActive": true,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
}
]

View File

@@ -0,0 +1,32 @@
[
{
"id": "1",
"nama": "Admin Desa",
"nomor": "089647037426",
"roleId": "1",
"isActive": true,
"lastLogin": "2025-08-31T10:00:00.000Z",
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "2",
"isActive": true,
"lastLogin": null,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "3",
"isActive": true,
"lastLogin": null,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
}
]

View File

@@ -202,7 +202,7 @@ model PrestasiDesa {
deskripsi String @db.Text deskripsi String @db.Text
kategori KategoriPrestasiDesa @relation(fields: [kategoriId], references: [id]) kategori KategoriPrestasiDesa @relation(fields: [kategoriId], references: [id])
kategoriId String kategoriId String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -293,9 +293,9 @@ model PosisiOrganisasiPPID {
pegawai PegawaiPPID[] pegawai PegawaiPPID[]
strukturOrganisasi StrukturPPID[] // Relasi balik strukturOrganisasi StrukturPPID[] // Relasi balik
parentId String? parentId String?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id]) parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
children PosisiOrganisasiPPID[] @relation("Parent") children PosisiOrganisasiPPID[] @relation("Parent")
} }
@@ -1639,28 +1639,29 @@ model ProgramKreatif {
// ========================================= KOLABORASI INOVASI ========================================= // // ========================================= KOLABORASI INOVASI ========================================= //
model KolaborasiInovasi { model KolaborasiInovasi {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
tahun Int tahun Int
slug String @db.Text //deskripsi singkat slug String @db.Text //deskripsi singkat
deskripsi String @db.Text //deskripsi panjang deskripsi String @db.Text //deskripsi panjang
kolaborator String kolaborator String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
model MitraKolaborasi { model MitraKolaborasi {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
// ========================================= INFO TEKHNOLOGI TEPAT GUNA ========================================= // // ========================================= INFO TEKHNOLOGI TEPAT GUNA ========================================= //
model InfoTekno { model InfoTekno {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -2102,26 +2103,66 @@ model KategoriBuku {
DataPerpustakaan DataPerpustakaan[] DataPerpustakaan DataPerpustakaan[]
} }
// ========================================= USER ========================================= //
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
nama String username String
email String @unique nomor String @unique
password String role Role @relation(fields: [roleId], references: [id])
role Role @relation(fields: [roleId], references: [id]) roleId String @default("1")
roleId String instansi String?
isActive Boolean @default(true) UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @updatedAt lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
} }
model Role { model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json // Menyimpan permission dalam format JSON
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
users User[]
@@map("roles")
}
model KodeOtp {
id String @id @default(cuid()) id String @id @default(cuid())
name String isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) nomor String
isActive Boolean @default(true) otp Int
User User[] }
// Tabel untuk menyimpan permission
model Permission {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("permissions")
}
model UserSession {
id String @id @default(cuid())
token String
expires DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique
} }
// ========================================= DATA PENDIDIKAN ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -52,8 +52,65 @@ import tempatKegiatan from "./data/pendidikan/pendidikan-non-formal/tempat-kegia
import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-program2.json"; import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-program2.json";
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json"; import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json"; import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
(async () => { (async () => {
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
for (const r of roles) {
await prisma.role.upsert({
where: { id: r.id },
update: {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
},
create: {
id: r.id,
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
},
});
}
console.log("✅ Roles seeded");
// =========== USERS ===========
console.log("🔄 Seeding users...");
for (const u of users) {
// First verify the role exists
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId }
});
if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
continue;
}
await prisma.user.upsert({
where: { id: u.id },
update: {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
},
create: {
id: u.id,
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
},
});
}
console.log("✅ Users seeded");
// =========== LANDING PAGE =========== // =========== LANDING PAGE ===========
// =========== SUBMENU PROFILE =========== // =========== SUBMENU PROFILE ===========
// =========== PROFILE PEJABAT DESA =========== // =========== PROFILE PEJABAT DESA ===========

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,27 +115,38 @@ const artikelKesehatanState = proxy({
}; };
}>[] }>[]
| null, | null,
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
load: async (page = 1, limit = 10, search = "") => {
artikelKesehatanState.findMany.loading = true; // ✅ Akses langsung via nama path
artikelKesehatanState.findMany.page = page;
artikelKesehatanState.findMany.search = search;
try { try {
this.loading = true; const query: any = { page, limit };
const res = await (ApiFetch.api.kesehatan as any)["artikel-kesehatan"][ if (search) query.search = search;
const res = await ApiFetch.api.kesehatan["artikel-kesehatan"][
"find-many" "find-many"
].get(); ].get({ query });
if (res.status === 200) { if (res.status === 200 && res.data?.success) {
this.data = res.data?.data ?? []; artikelKesehatanState.findMany.data =
res.data.data ?? [];
artikelKesehatanState.findMany.totalPages =
res.data.totalPages ?? 1;
} else { } else {
toast.error("Gagal memuat data artikel kesehatan"); artikelKesehatanState.findMany.data = [];
artikelKesehatanState.findMany.totalPages = 1;
} }
return res;
} catch (err) { } catch (err) {
toast.error("Terjadi error saat load data"); console.error("Gagal fetch artikel kesehatan paginated:", err);
console.error("LOAD ERROR:", err); artikelKesehatanState.findMany.data = [];
throw err; artikelKesehatanState.findMany.totalPages = 1;
} finally { } finally {
this.loading = false; artikelKesehatanState.findMany.loading = false;
} }
}, },
}, },
@@ -280,12 +291,9 @@ const artikelKesehatanState = proxy({
async byId(id: string) { async byId(id: string) {
try { try {
artikelKesehatanState.delete.loading = true; artikelKesehatanState.delete.loading = true;
const res = await fetch( const res = await fetch(`/api/kesehatan/artikel-kesehatan/del/${id}`, {
`/api/kesehatan/artikel-kesehatan/del/${id}`, method: "DELETE",
{ });
method: "DELETE",
}
);
const result = await res.json(); const result = await res.json();
if (res.ok && result.success) { if (res.ok && result.success) {

View File

@@ -116,27 +116,38 @@ const fasilitasKesehatan = proxy({
}; };
}>[] }>[]
| null, | null,
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
load: async (page = 1, limit = 10, search = "") => {
fasilitasKesehatanState.fasilitasKesehatan.findMany.loading = true; // ✅ Akses langsung via nama path
fasilitasKesehatanState.fasilitasKesehatan.findMany.page = page;
fasilitasKesehatanState.fasilitasKesehatan.findMany.search = search;
try { try {
this.loading = true; const query: any = { page, limit };
const res = await (ApiFetch.api.kesehatan as any)[ if (search) query.search = search;
"fasilitas-kesehatan"
]["find-many"].get();
if (res.status === 200) { const res = await ApiFetch.api.kesehatan["fasilitas-kesehatan"][
this.data = res.data?.data ?? []; "find-many"
].get({ query });
if (res.status === 200 && res.data?.success) {
fasilitasKesehatanState.fasilitasKesehatan.findMany.data =
res.data.data ?? [];
fasilitasKesehatanState.fasilitasKesehatan.findMany.totalPages =
res.data.totalPages ?? 1;
} else { } else {
toast.error("Gagal memuat data fasilitas kesehatan"); fasilitasKesehatanState.fasilitasKesehatan.findMany.data = [];
fasilitasKesehatanState.fasilitasKesehatan.findMany.totalPages = 1;
} }
return res;
} catch (err) { } catch (err) {
toast.error("Terjadi error saat load data"); console.error("Gagal fetch fasilitas kesehatan paginated:", err);
console.error("LOAD ERROR:", err); fasilitasKesehatanState.fasilitasKesehatan.findMany.data = [];
throw err; fasilitasKesehatanState.fasilitasKesehatan.findMany.totalPages = 1;
} finally { } finally {
this.loading = false; fasilitasKesehatanState.fasilitasKesehatan.findMany.loading = false;
} }
}, },
}, },
@@ -558,7 +569,7 @@ const dokter = proxy({
const fasilitasKesehatanState = proxy({ const fasilitasKesehatanState = proxy({
fasilitasKesehatan, fasilitasKesehatan,
dokter dokter,
}); });
export default fasilitasKesehatanState; export default fasilitasKesehatanState;

View File

@@ -120,27 +120,36 @@ const jadwalkegiatanState = proxy({
}; };
}>[] }>[]
| null, | null,
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
load: async (page = 1, limit = 10, search = "") => {
jadwalkegiatanState.findMany.loading = true; // ✅ Akses langsung via nama path
jadwalkegiatanState.findMany.page = page;
jadwalkegiatanState.findMany.search = search;
try { try {
this.loading = true; const query: any = { page, limit };
const res = await (ApiFetch.api.kesehatan as any)[ if (search) query.search = search;
"jadwal-kegiatan"
]["find-many"].get();
if (res.status === 200) { const res = await ApiFetch.api.kesehatan["jadwal-kegiatan"][
this.data = res.data?.data ?? []; "find-many"
].get({ query });
if (res.status === 200 && res.data?.success) {
jadwalkegiatanState.findMany.data = res.data.data ?? [];
jadwalkegiatanState.findMany.totalPages = res.data.totalPages ?? 1;
} else { } else {
toast.error("Gagal memuat data jadwal kegiatan"); jadwalkegiatanState.findMany.data = [];
jadwalkegiatanState.findMany.totalPages = 1;
} }
return res;
} catch (err) { } catch (err) {
toast.error("Terjadi error saat load data"); console.error("Gagal fetch jadwal kegiatan paginated:", err);
console.error("LOAD ERROR:", err); jadwalkegiatanState.findMany.data = [];
throw err; jadwalkegiatanState.findMany.totalPages = 1;
} finally { } finally {
this.loading = false; jadwalkegiatanState.findMany.loading = false;
} }
}, },
}, },
@@ -227,29 +236,42 @@ const jadwalkegiatanState = proxy({
content: jadwalkegiatanState.edit.form.content, content: jadwalkegiatanState.edit.form.content,
informasiJadwalKegiatan: { informasiJadwalKegiatan: {
name: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.name, name: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.name,
tanggal: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.tanggal, tanggal:
jadwalkegiatanState.edit.form.informasiJadwalKegiatan.tanggal,
waktu: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.waktu, waktu: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.waktu,
lokasi: jadwalkegiatanState.edit.form.informasiJadwalKegiatan.lokasi, lokasi:
jadwalkegiatanState.edit.form.informasiJadwalKegiatan.lokasi,
}, },
layananJadwalKegiatan: { layananJadwalKegiatan: {
content: jadwalkegiatanState.edit.form.layananJadwalKegiatan.content, content:
jadwalkegiatanState.edit.form.layananJadwalKegiatan.content,
}, },
deskripsiJadwalKegiatan: { deskripsiJadwalKegiatan: {
deskripsi: jadwalkegiatanState.edit.form.deskripsiJadwalKegiatan.deskripsi, deskripsi:
jadwalkegiatanState.edit.form.deskripsiJadwalKegiatan.deskripsi,
}, },
syaratKetentuanJadwalKegiatan: { syaratKetentuanJadwalKegiatan: {
content: jadwalkegiatanState.edit.form.syaratKetentuanJadwalKegiatan.content, content:
jadwalkegiatanState.edit.form.syaratKetentuanJadwalKegiatan
.content,
}, },
dokumenJadwalKegiatan: { dokumenJadwalKegiatan: {
content: jadwalkegiatanState.edit.form.dokumenJadwalKegiatan.content, content:
jadwalkegiatanState.edit.form.dokumenJadwalKegiatan.content,
}, },
pendaftaranJadwalKegiatan: { pendaftaranJadwalKegiatan: {
name: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.name, name: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.name,
tanggal: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.tanggal, tanggal:
namaOrangtua: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.namaOrangtua, jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.tanggal,
nomor: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.nomor, namaOrangtua:
alamat: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.alamat, jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan
catatan: jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.catatan, .namaOrangtua,
nomor:
jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.nomor,
alamat:
jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.alamat,
catatan:
jadwalkegiatanState.edit.form.pendaftaranJadwalKegiatan.catatan,
}, },
}; };
@@ -286,7 +308,7 @@ const jadwalkegiatanState = proxy({
}, },
delete: { delete: {
loading: false, loading: false,
async byId(id: string){ async byId(id: string) {
try { try {
jadwalkegiatanState.delete.loading = true; jadwalkegiatanState.delete.loading = true;
const res = await fetch(`/api/kesehatan/jadwal-kegiatan/del/${id}`, { const res = await fetch(`/api/kesehatan/jadwal-kegiatan/del/${id}`, {
@@ -305,7 +327,7 @@ const jadwalkegiatanState = proxy({
} finally { } finally {
jadwalkegiatanState.delete.loading = false; jadwalkegiatanState.delete.loading = false;
} }
} },
}, },
}); });

View File

@@ -269,7 +269,7 @@ const keteranganSampah = proxy({
try { try {
keteranganSampah.create.loading = true; keteranganSampah.create.loading = true;
const res = const res =
await ApiFetch.api.lingkungan.pengelolaansampah.keteranganbankterdekat[ await ApiFetch.api.lingkungan.keteranganbankterdekat[
"create" "create"
].post(keteranganSampah.create.form); ].post(keteranganSampah.create.form);
if (res.status === 200) { if (res.status === 200) {
@@ -291,14 +291,47 @@ const keteranganSampah = proxy({
omit: { isActive: true }; omit: { isActive: true };
}>[] }>[]
| null, | null,
async load() { page: 1,
const res = await ApiFetch.api.lingkungan.pengelolaansampah.keteranganbankterdekat[ totalPages: 1,
"find-many" total: 0,
].get(); loading: false,
if (res.status === 200) { search: "",
keteranganSampah.findMany.data = res.data?.data ?? []; load: async (page = 1, limit = 10, search = "") => {
} // Change to arrow function
}, keteranganSampah.findMany.loading = true; // Use the full path to access the property
keteranganSampah.findMany.page = page;
keteranganSampah.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.lingkungan.keteranganbankterdekat[
"find-many"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
keteranganSampah.findMany.data = res.data.data || [];
keteranganSampah.findMany.total = res.data.total || 0;
keteranganSampah.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error(
"Failed to load keterangan bank sampah terdekat:",
res.data?.message
);
keteranganSampah.findMany.data = [];
keteranganSampah.findMany.total = 0;
keteranganSampah.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading keterangan bank sampah terdekat:", error);
keteranganSampah.findMany.data = [];
keteranganSampah.findMany.total = 0;
keteranganSampah.findMany.totalPages = 1;
} finally {
keteranganSampah.findMany.loading = false;
}
},
}, },
findUnique: { findUnique: {
data: null as Prisma.KeteranganBankSampahTerdekatGetPayload<{ data: null as Prisma.KeteranganBankSampahTerdekatGetPayload<{
@@ -306,7 +339,7 @@ const keteranganSampah = proxy({
}> | null, }> | null,
async load(id: string) { async load(id: string) {
try { try {
const res = await fetch(`/api/lingkungan/pengelolaansampah/keteranganbankterdekat/${id}`); const res = await fetch(`/api/lingkungan/keteranganbankterdekat/${id}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
keteranganSampah.findUnique.data = data.data ?? null; keteranganSampah.findUnique.data = data.data ?? null;
@@ -328,7 +361,7 @@ const keteranganSampah = proxy({
try { try {
keteranganSampah.delete.loading = true; keteranganSampah.delete.loading = true;
const response = await fetch(`/api/lingkungan/pengelolaansampah/keteranganbankterdekat/del/${id}`, { const response = await fetch(`/api/lingkungan/keteranganbankterdekat/del/${id}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -363,7 +396,7 @@ const keteranganSampah = proxy({
} }
try { try {
const response = await fetch(`/api/lingkungan/pengelolaansampah/keteranganbankterdekat/${id}`, { const response = await fetch(`/api/lingkungan/keteranganbankterdekat/${id}`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -408,7 +441,7 @@ const keteranganSampah = proxy({
try { try {
keteranganSampah.edit.loading = true; keteranganSampah.edit.loading = true;
const response = await fetch( const response = await fetch(
`/api/lingkungan/pengelolaansampah/keteranganbankterdekat/${this.id}`, `/api/lingkungan/keteranganbankterdekat/${this.id}`,
{ {
method: "PUT", method: "PUT",
headers: { headers: {

View File

@@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// ========================================= BEASISWA PENDAFTAR ========================================= //
const templateBeasiswaPendaftar = z.object({ const templateBeasiswaPendaftar = z.object({
namaLengkap: z.string().min(1, "Nama harus diisi"), namaLengkap: z.string().min(1, "Nama harus diisi"),
nik: z.string().min(1, "NIK harus diisi"), nik: z.string().min(1, "NIK harus diisi"),
@@ -76,13 +79,34 @@ const beasiswaPendaftar = proxy({
isActive: true; isActive: true;
}; };
}>[], }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
const res = await ApiFetch.api.pendidikan.beasiswa.beasiswapendaftar[ load: async (page = 1, limit = 10, search = "") => {
"findMany" beasiswaPendaftar.findMany.loading = true; // ✅ Akses langsung via nama path
].get(); beasiswaPendaftar.findMany.page = page;
if (res.status === 200) { beasiswaPendaftar.findMany.search = search;
beasiswaPendaftar.findMany.data = res.data?.data ?? [];
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.beasiswa.beasiswapendaftar["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
beasiswaPendaftar.findMany.data = res.data.data ?? [];
beasiswaPendaftar.findMany.totalPages = res.data.totalPages ?? 1;
} else {
beasiswaPendaftar.findMany.data = [];
beasiswaPendaftar.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch beasiswa pendaftar paginated:", err);
beasiswaPendaftar.findMany.data = [];
beasiswaPendaftar.findMany.totalPages = 1;
} finally {
beasiswaPendaftar.findMany.loading = false;
} }
}, },
}, },
@@ -275,8 +299,260 @@ const beasiswaPendaftar = proxy({
}, },
}); });
// ========================================= KEUNGGULAN PROGRAM ========================================= //
const templateKeunggulanProgram = z.object({
judul: z.string().min(1, "Judul harus diisi"),
deskripsi: z.string().min(1, "Deskripsi harus diisi"),
});
const defaultKeunggulanProgram = {
judul: "",
deskripsi: "",
};
const keunggulanProgram = proxy({
create: {
form: { ...defaultKeunggulanProgram },
loading: false,
async create() {
const cek = templateKeunggulanProgram.safeParse(
keunggulanProgram.create.form
);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
keunggulanProgram.create.loading = true;
const res = await ApiFetch.api.pendidikan.beasiswa.keunggulanprogram[
"create"
].post(keunggulanProgram.create.form);
if (res.status === 200) {
keunggulanProgram.findMany.load();
return toast.success("Data Berhasil Dibuat, Silahkan Menunggu Konfirmasi dari Admin di WhatsApp");
}
console.log(res);
return toast.error("failed create");
} catch (error) {
console.log(error);
return toast.error("failed create");
} finally {
keunggulanProgram.create.loading = false;
}
},
},
findMany: {
data: [] as Prisma.KeunggulanProgramGetPayload<{
omit: {
isActive: true;
};
}>[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
keunggulanProgram.findMany.loading = true; // ✅ Akses langsung via nama path
keunggulanProgram.findMany.page = page;
keunggulanProgram.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.beasiswa.keunggulanprogram["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
keunggulanProgram.findMany.data = res.data.data ?? [];
keunggulanProgram.findMany.totalPages = res.data.totalPages ?? 1;
} else {
keunggulanProgram.findMany.data = [];
keunggulanProgram.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch keunggulan program paginated:", err);
keunggulanProgram.findMany.data = [];
keunggulanProgram.findMany.totalPages = 1;
} finally {
keunggulanProgram.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.KeunggulanProgramGetPayload<{
omit: {
isActive: true;
};
}> | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(
`/api/pendidikan/beasiswa/keunggulanprogram/${id}`
);
if (res.ok) {
const data = await res.json();
keunggulanProgram.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
keunggulanProgram.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
keunggulanProgram.findUnique.data = null;
}
},
},
delete: {
loading: false,
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
keunggulanProgram.delete.loading = true;
const response = await fetch(
`/api/pendidikan/beasiswa/keunggulanprogram/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Keunggulan Program berhasil dihapus");
await keunggulanProgram.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus keunggulan program");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus keunggulan program");
} finally {
keunggulanProgram.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...defaultKeunggulanProgram },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(
`/api/pendidikan/beasiswa/keunggulanprogram/${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 = {
judul: data.judul,
deskripsi: data.deskripsi,
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading keunggulan program:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templateKeunggulanProgram.safeParse(
keunggulanProgram.update.form
);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
keunggulanProgram.update.loading = true;
const response = await fetch(
`/api/pendidikan/beasiswa/keunggulanprogram/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
judul: this.form.judul,
deskripsi: this.form.deskripsi,
}),
}
);
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 keunggulan program");
await keunggulanProgram.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update keunggulan program");
}
} catch (error) {
console.error("Error updating keunggulan program:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update keunggulan program"
);
return false;
} finally {
keunggulanProgram.update.loading = false;
}
},
reset() {
keunggulanProgram.update.id = "";
keunggulanProgram.update.form = { ...defaultKeunggulanProgram };
},
},
});
const beasiswaDesaState = proxy({ const beasiswaDesaState = proxy({
beasiswaPendaftar, beasiswaPendaftar,
keunggulanProgram
}); });
export default beasiswaDesaState; export default beasiswaDesaState;

View File

@@ -333,43 +333,56 @@ const lembagaPendidikan = proxy({
Prisma.LembagaGetPayload<{ Prisma.LembagaGetPayload<{
include: { include: {
jenjangPendidikan: true; jenjangPendidikan: true;
siswa: true;
pengajar: true;
}; };
}> }> & {
siswa?: [];
pengajar?: [];
}
> | null, > | null,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit = 10, search = "", jenjangPendidikan = "") => {
// Change to arrow function lembagaPendidikan.findMany.loading = true;
lembagaPendidikan.findMany.loading = true; // Use the full path to access the property
lembagaPendidikan.findMany.page = page; lembagaPendidikan.findMany.page = page;
lembagaPendidikan.findMany.search = search; lembagaPendidikan.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = {
if (search) query.search = search; page,
const res = limit,
await ApiFetch.api.pendidikan.infosekolahpaud.lembagapendidikan[ ...(search && { search }),
"find-many" ...(jenjangPendidikan && { jenjangPendidikanId: jenjangPendidikan })
].get({ };
query,
}); console.log('Fetching lembaga with query:', query);
const res = await ApiFetch.api.pendidikan.infosekolahpaud.lembagapendidikan["find-many"].get({ query });
console.log('API Response:', res);
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
lembagaPendidikan.findMany.data = res.data.data || []; const data = Array.isArray(res.data.data) ? res.data.data : [];
lembagaPendidikan.findMany.total = res.data.total || 0; const total = typeof res.data.total === 'number' ? res.data.total : 0;
lembagaPendidikan.findMany.totalPages = res.data.totalPages || 1; const totalPages = typeof res.data.totalPages === 'number' ? res.data.totalPages : 1;
lembagaPendidikan.findMany.data = data;
lembagaPendidikan.findMany.total = total;
lembagaPendidikan.findMany.totalPages = totalPages;
console.log('Successfully loaded lembaga data:', {
count: data.length,
total,
totalPages
});
} else { } else {
console.error( console.error(
"Failed to load lembaga pendidikan:", "Failed to load lembaga pendidikan:",
res.data?.message res.data?.message || 'No error message provided'
); );
lembagaPendidikan.findMany.data = []; throw new Error(res.data?.message || 'Failed to load lembaga pendidikan');
lembagaPendidikan.findMany.total = 0;
lembagaPendidikan.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading lembaga pendidikan:", error); console.error("Error loading lembaga pendidikan:", error);
@@ -621,7 +634,11 @@ const siswa = proxy({
data: null as Array< data: null as Array<
Prisma.SiswaGetPayload<{ Prisma.SiswaGetPayload<{
include: { include: {
lembaga: true; lembaga: {
include: {
jenjangPendidikan: true;
};
};
}; };
}> }>
> | null, > | null,
@@ -630,14 +647,16 @@ const siswa = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { jenjangPendidikan: "",
// Change to arrow function load: async (page = 1, limit = 10, search = "", jenjangPendidikan = "") => {
siswa.findMany.loading = true; // Use the full path to access the property siswa.findMany.loading = true;
siswa.findMany.page = page; siswa.findMany.page = page;
siswa.findMany.search = search; siswa.findMany.search = search;
siswa.findMany.jenjangPendidikan = jenjangPendidikan;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
if (jenjangPendidikan) query.jenjangPendidikanName = jenjangPendidikan;
const res = await ApiFetch.api.pendidikan.infosekolahpaud.siswa[ const res = await ApiFetch.api.pendidikan.infosekolahpaud.siswa[
"find-many" "find-many"
].get({ ].get({
@@ -894,7 +913,11 @@ const pengajar = proxy({
data: null as Array< data: null as Array<
Prisma.PengajarGetPayload<{ Prisma.PengajarGetPayload<{
include: { include: {
lembaga: true; lembaga: {
include: {
jenjangPendidikan: true
}
}
}; };
}> }>
> | null, > | null,
@@ -903,14 +926,17 @@ const pengajar = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { jenjangPendidikan: "",
load: async (page = 1, limit = 10, search = "", jenjangPendidikan = "") => {
// Change to arrow function // Change to arrow function
pengajar.findMany.loading = true; // Use the full path to access the property pengajar.findMany.loading = true; // Use the full path to access the property
pengajar.findMany.page = page; pengajar.findMany.page = page;
pengajar.findMany.search = search; pengajar.findMany.search = search;
pengajar.findMany.jenjangPendidikan = jenjangPendidikan;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
if (jenjangPendidikan) query.jenjangPendidikanId = jenjangPendidikan;
const res = await ApiFetch.api.pendidikan.infosekolahpaud.pengajar[ const res = await ApiFetch.api.pendidikan.infosekolahpaud.pengajar[
"find-many" "find-many"
].get({ ].get({

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -54,23 +55,46 @@ const dataPerpustakaan = proxy({
}, },
}, },
findMany: { findMany: {
data: [] as Prisma.DataPerpustakaanGetPayload<{ data: null as
include: { | Prisma.DataPerpustakaanGetPayload<{
kategori: true; include: {
image: true; image: true;
}; kategori: true;
}>[], };
loading: false, }>[]
async load() { | null,
const res = page: 1,
await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan[ totalPages: 1,
"findMany" loading: false,
].get(); search: "",
if (res.status === 200) { load: async (page = 1, limit = 10, search = "", kategori = "") => {
dataPerpustakaan.findMany.data = res.data?.data ?? []; dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path
} dataPerpustakaan.findMany.page = page;
dataPerpustakaan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
dataPerpustakaan.findMany.data = res.data.data ?? [];
dataPerpustakaan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
dataPerpustakaan.findMany.data = [];
dataPerpustakaan.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data perpustakaan paginated:", err);
dataPerpustakaan.findMany.data = [];
dataPerpustakaan.findMany.totalPages = 1;
} finally {
dataPerpustakaan.findMany.loading = false;
}
},
}, },
},
findUnique: { findUnique: {
data: null as Prisma.DataPerpustakaanGetPayload<{ data: null as Prisma.DataPerpustakaanGetPayload<{
include: { include: {
@@ -293,14 +317,34 @@ const kategoriBuku = proxy({
isActive: true; isActive: true;
}; };
}>[], }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
const res = load: async (page = 1, limit = 10, search = "") => {
await ApiFetch.api.pendidikan.perpustakaandigital.kategoribuku[ kategoriBuku.findMany.loading = true; // ✅ Akses langsung via nama path
"findMany" kategoriBuku.findMany.page = page;
].get(); kategoriBuku.findMany.search = search;
if (res.status === 200) {
kategoriBuku.findMany.data = res.data?.data ?? []; try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.kategoribuku["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriBuku.findMany.data = res.data.data ?? [];
kategoriBuku.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriBuku.findMany.data = [];
kategoriBuku.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data kategori buku paginated:", err);
kategoriBuku.findMany.data = [];
kategoriBuku.findMany.totalPages = 1;
} finally {
kategoriBuku.findMany.loading = false;
} }
}, },
}, },

View File

@@ -1,124 +1,43 @@
import { proxy } from 'valtio' /* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from 'react-toastify' import { proxy } from "valtio";
import ApiFetch from '@/lib/api-fetch' import { toast } from "react-toastify";
import { Prisma } from '@prisma/client' import ApiFetch from "@/lib/api-fetch";
import { z } from 'zod' import { Prisma } from "@prisma/client";
import { z } from "zod";
// 1. Validasi Zod // State Valtio
const userSchema = z.object({
nama: z.string().min(1, 'Nama harus diisi'),
email: z.string().email('Email tidak valid'),
password: z.string().min(6, 'Password minimal 6 karakter'),
roleId: z.string().optional(),
})
const defaultForm = { nama: '', email: '', password: '', roleId: '' }
// 2. State Valtio
const userState = proxy({ const userState = proxy({
// Register
register: {
form: { ...defaultForm },
loading: false,
async submit() {
const valid = userSchema.omit({ roleId: true }).safeParse(userState.register.form)
if (!valid.success) {
const err = valid.error.issues.map(i => i.message).join(', ')
return toast.error(err)
}
try {
userState.register.loading = true
const res = await ApiFetch.api.user.register.post(userState.register.form)
if (res.status === 200) {
toast.success('Registrasi berhasil, silakan login')
userState.register.form = { ...defaultForm } // reset
} else {
toast.error(res.data?.message || 'Gagal registrasi')
}
} catch (e) {
console.error(e)
toast.error('Terjadi kesalahan saat registrasi')
} finally {
userState.register.loading = false
}
},
},
// Login
login: {
form: { email: '', password: '' },
loading: false,
async submit() {
try {
userState.login.loading = true
const res = await ApiFetch.api.user.login.post(userState.login.form)
if (res.status === 200) {
toast.success('Login berhasil')
const token = res.data?.data?.token
if (typeof token === 'string') {
localStorage.setItem('token', token)
// Optional: simpan user role untuk otorisasi
const user = res.data?.data?.user
localStorage.setItem('user', JSON.stringify(user))
}
} else {
toast.error(res.data?.message || 'Login gagal')
}
} catch (e) {
console.error(e)
toast.error('Terjadi kesalahan saat login')
} finally {
userState.login.loading = false
}
},
},
// CRUD User (untuk admin)
create: {
form: { ...defaultForm },
loading: false,
async create(isAdmin = false) {
const valid = userSchema.safeParse(userState.create.form)
if (!valid.success) {
const err = valid.error.issues.map(i => i.message).join(', ')
return toast.error(err)
}
try {
userState.create.loading = true
const endpoint = isAdmin ? 'create' : 'register'
const res = await ApiFetch.api.user[endpoint].post(userState.create.form)
if (res.status === 200) {
toast.success('User berhasil dibuat')
userState.findMany.load() // refresh list
userState.create.form = { ...defaultForm } // reset form
} else {
toast.error(res.data?.message || 'Gagal membuat user')
}
} catch (e) {
console.error(e)
toast.error('Gagal membuat user')
} finally {
userState.create.loading = false
}
},
},
// Find Many // Find Many
findMany: { findMany: {
data: [] as Prisma.UserGetPayload<{ include: { role: true } }>[], data: [] as Prisma.UserGetPayload<{ include: { role: true } }>[],
page: 1,
totalPages: 1,
loading: false, loading: false,
async load() { search: "",
this.loading = true load: async (page = 1, limit = 10, search = "") => {
userState.findMany.loading = true; // ✅ Akses langsung via nama path
userState.findMany.page = page;
userState.findMany.search = search;
try { try {
const res = await ApiFetch.api.user.findMany.get() const query: any = { page, limit };
if (res.status === 200) { if (search) query.search = search;
this.data = res.data?.data || []
const res = await ApiFetch.api.user["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
userState.findMany.data = res.data.data ?? [];
userState.findMany.totalPages = res.data.totalPages ?? 1;
} else {
userState.findMany.data = [];
userState.findMany.totalPages = 1;
} }
} catch (e) { } catch (err) {
console.error(e) console.error("Gagal fetch user paginated:", err);
toast.error('Gagal muat data user') userState.findMany.data = [];
userState.findMany.totalPages = 1;
} finally { } finally {
this.loading = false userState.findMany.loading = false;
} }
}, },
}, },
@@ -128,71 +47,20 @@ const userState = proxy({
data: null as Prisma.UserGetPayload<{ include: { role: true } }> | null, data: null as Prisma.UserGetPayload<{ include: { role: true } }> | null,
loading: false, loading: false,
async load(id: string) { async load(id: string) {
this.loading = true this.loading = true;
try { try {
const res = await fetch(`/api/user/findUnique/${id}`) const res = await fetch(`/api/user/findUnique/${id}`);
const data = await res.json() const data = await res.json();
if (res.status === 200) { if (res.status === 200) {
this.data = data.data this.data = data.data;
} else { } else {
toast.error(data.message) toast.error(data.message);
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e);
toast.error('Gagal ambil data user') toast.error("Gagal ambil data user");
} finally { } finally {
this.loading = false this.loading = false;
}
},
},
// Update
update: {
id: '',
form: { ...defaultForm },
loading: false,
async load(id: string) {
this.loading = true
try {
const res = await fetch(`/api/user/findUnique/${id}`)
const data = await res.json()
if (res.status === 200) {
const user = data.data
this.id = user.id
this.form = {
nama: user.nama,
email: user.email,
password: '', // jangan kirim password lama
roleId: user.roleId,
}
}
} catch (e) {
console.error(e)
toast.error('Gagal muat data')
} finally {
this.loading = false
}
},
async submit() {
this.loading = true
try {
const res = await fetch(`/api/user/update/${this.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
})
const data = await res.json()
if (res.status === 200) {
toast.success('Update berhasil')
userState.findMany.load()
} else {
toast.error(data.message || 'Gagal update')
}
} catch (e) {
console.error(e)
toast.error('Gagal update user')
} finally {
this.loading = false
} }
}, },
}, },
@@ -201,35 +69,63 @@ const userState = proxy({
delete: { delete: {
loading: false, loading: false,
async submit(id: string) { async submit(id: string) {
this.loading = true this.loading = true;
try { try {
const res = await fetch(`/api/user/del/${id}`, { const res = await fetch(`/api/user/del/${id}`, {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
}) });
const data = await res.json() const data = await res.json();
if (res.status === 200) { if (res.status === 200) {
toast.success('User dinonaktifkan') toast.success("User dinonaktifkan");
userState.findMany.load() userState.findMany.load();
} else { } else {
toast.error(data.message || 'Gagal hapus') toast.error(data.message || "Gagal hapus");
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e);
toast.error('Gagal hapus user') toast.error("Gagal hapus user");
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
}, },
}) updateActive: {
loading: false,
async submit(id: string, isActive: boolean) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, isActive }),
});
const data = await res.json();
if (res.status === 200 && data.success) {
toast.success(data.message);
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
} else {
toast.error(data.message || "Gagal update status user");
}
} catch (e) {
console.error(e);
toast.error("Gagal update status user");
} finally {
this.loading = false;
}
},
},
});
const templateRole = z.object({ const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
}); });
const defaultRole = { const defaultRole = {
name: "", name: "",
permissions: [] as string[],
}; };
const roleState = proxy({ const roleState = proxy({
@@ -247,10 +143,9 @@ const roleState = proxy({
try { try {
roleState.create.loading = true; roleState.create.loading = true;
const res = const res = await ApiFetch.api.role["create"].post(
await ApiFetch.api.role[ roleState.create.form
"create" );
].post(roleState.create.form);
if (res.status === 200) { if (res.status === 200) {
roleState.findMany.load(); roleState.findMany.load();
return toast.success("Data role Berhasil Dibuat"); return toast.success("Data role Berhasil Dibuat");
@@ -273,10 +168,7 @@ const roleState = proxy({
}>[], }>[],
loading: false, loading: false,
async load() { async load() {
const res = const res = await ApiFetch.api.role["findMany"].get();
await ApiFetch.api.role[
"findMany"
].get();
if (res.status === 200) { if (res.status === 200) {
roleState.findMany.data = res.data?.data ?? []; roleState.findMany.data = res.data?.data ?? [];
} }
@@ -291,9 +183,7 @@ const roleState = proxy({
loading: false, loading: false,
async load(id: string) { async load(id: string) {
try { try {
const res = await fetch( const res = await fetch(`/api/role/${id}`);
`/api/role/${id}`
);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
roleState.findUnique.data = data.data ?? null; roleState.findUnique.data = data.data ?? null;
@@ -315,22 +205,17 @@ const roleState = proxy({
try { try {
roleState.delete.loading = true; roleState.delete.loading = true;
const response = await fetch( const response = await fetch(`/api/role/del/${id}`, {
`/api/role/del/${id}`, method: "DELETE",
{ headers: {
method: "DELETE", "Content-Type": "application/json",
headers: { },
"Content-Type": "application/json", });
},
}
);
const result = await response.json(); const result = await response.json();
if (response.ok && result?.success) { if (response.ok && result?.success) {
toast.success( toast.success(result.message || "Data role berhasil dihapus");
result.message || "Data role berhasil dihapus"
);
await roleState.findMany.load(); // refresh list await roleState.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus Data role"); toast.error(result?.message || "Gagal menghapus Data role");
@@ -354,15 +239,12 @@ const roleState = proxy({
} }
try { try {
const response = await fetch( const response = await fetch(`/api/role/${id}`, {
`/api/role/${id}`, method: "GET",
{ headers: {
method: "GET", "Content-Type": "application/json",
headers: { },
"Content-Type": "application/json", });
},
}
);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@@ -374,6 +256,7 @@ const roleState = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name, name: data.name,
permissions: data.permissions,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -400,18 +283,16 @@ const roleState = proxy({
try { try {
roleState.update.loading = true; roleState.update.loading = true;
const response = await fetch( const response = await fetch(`/api/role/${this.id}`, {
`/api/role/${this.id}`, method: "PUT",
{ headers: {
method: "PUT", "Content-Type": "application/json",
headers: { },
"Content-Type": "application/json", body: JSON.stringify({
}, name: this.form.name,
body: JSON.stringify({ permissions: this.form.permissions,
name: this.form.name, }),
}), });
}
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
@@ -451,6 +332,6 @@ const roleState = proxy({
const user = proxy({ const user = proxy({
userState, userState,
roleState, roleState,
}) });
export default user export default user;

View File

@@ -0,0 +1,111 @@
'use client'
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { toast } from 'react-toastify';
function Login() {
const router = useRouter()
const [phone, setPhone] = useState("")
const [isError, setError] = useState(false)
const [loading, setLoading] = useState(false)
async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
try {
setLoading(true);
const response = await apiFetchLogin({ nomor: nomor })
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
toast.success(response.message);
router.push("/validasi", { scroll: false });
} else {
setLoading(false);
toast.error(response?.message);
}
} catch (error) {
setLoading(false)
console.log("Error Login", error)
toast.error("Terjadi kesalahan saat login")
}
}
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
Login
</Title>
<Center>
<Image src={"/darmasaba-icon.png"} alt="" w={80} />
</Center>
</Box>
<Box>
{/* <Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
<TextInput
label='Username'
placeholder='Username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Box> */}
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%"}}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
/>
{isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20} >
<Button
fullWidth
bg={colors['blue-button']}
radius={'xl'}
onClick={onLogin}
loading={loading ? true : false}
>Masuk
</Button>
</Box>
<Flex justify={'center'} align={'center'}>
<Text>Belum punya akun? </Text>
<Button variant='transparent' component={Link} href={'/registrasi'}>
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
</Button>
</Flex>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
</Stack>
);
}
export default Login;

View File

@@ -0,0 +1,121 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
'use client'
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { toast } from 'react-toastify';
function Registrasi() {
const [phone, setPhone] = useState("")
const router = useRouter()
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
const [loading, setLoading] = useState(false);
async function onRegistarsi() {
if (value.length < 5) {
toast.error("Username minimal 5 karakter!");
return;
}
if (value.includes(" ")) {
toast.error("Username tidak boleh ada spasi!");
return;
}
if (!phone) {
toast.error("Nomor telepon wajib diisi!");
return;
}
try {
setLoading(true);
const respone = await apiFetchRegister({ nomor: phone, username: value });
if (respone.success) {
router.push("/login", { scroll: false });
toast.success(respone.message);
} else {
setLoading(false);
toast.error(respone.message);
}
} catch (error) {
setLoading(false);
console.log("Error Registrasi", error);
}
}
return (
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Stack justify='center' align='center' h={"80vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center'>
<Title order={2} fw={'bold'} c={colors['blue-button']}>
Registrasi
</Title>
<Center>
<Image src={"/darmasaba-icon.png"} alt="" w={80} />
</Center>
<Box>
<TextInput placeholder='Username'
label='Username'
maxLength={50}
error={
value.length > 0 && value.length < 5
? "Minimal 5 karakter !"
: value.includes(" ")
? "Tidak boleh ada spasi"
: isValue
? "Masukan username anda"
: ""
}
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required
/>
<Box py={10}>
<Text fz={"sm"} >Nomor Telepon</Text>
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
/>
</Box>
<Box pb={10}>
<Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku"
/>
</Box>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
</Box>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
</Stack>
);
}
export default Registrasi;

View File

@@ -0,0 +1,38 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
function Validasi() {
const router = useRouter()
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
Kode Verifikasi
</Title>
</Box>
<Box>
<Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
</Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
Page
</Button>
</Box>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
</Stack>
);
}
export default Validasi;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,21 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from "@tabler/icons-react"; import {
IconArrowBack,
IconImageInPicture,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor"; import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import colors from "@/con/colors"; import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
@@ -34,24 +40,24 @@ function EditKeamananLingkungan() {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: keamananState.edit.form.name || '', name: keamananState.edit.form.name || "",
deskripsi: keamananState.edit.form.deskripsi || '', deskripsi: keamananState.edit.form.deskripsi || "",
imageId: keamananState.edit.form.imageId || '' imageId: keamananState.edit.form.imageId || "",
}); });
// Load berita by id saat pertama kali // Load data by id
useEffect(() => { useEffect(() => {
const loadBerita = async () => { const loadData = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await keamananState.edit.load(id); // akses langsung, bukan dari proxy const data = await keamananState.edit.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name || "",
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || "",
imageId: data.imageId || '', imageId: data.imageId || "",
}); });
if (data?.image?.link) { if (data?.image?.link) {
@@ -64,30 +70,29 @@ function EditKeamananLingkungan() {
} }
}; };
loadBerita(); loadData();
}, [params?.id]); // ✅ hapus beritaState dari dependency }, [params?.id]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
// Update global state with form data
keamananState.edit.form = { keamananState.edit.form = {
...keamananState.edit.form, ...keamananState.edit.form,
name: formData.name, name: formData.name,
deskripsi: formData.deskripsi, deskripsi: formData.deskripsi,
imageId: formData.imageId // Keep existing imageId if not changed imageId: formData.imageId,
}; };
// Jika ada file baru, upload
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error("Gagal upload gambar"); return toast.error("Gagal upload gambar");
} }
// Update imageId in global state
keamananState.edit.form.imageId = uploaded.id; keamananState.edit.form.imageId = uploaded.id;
} }
@@ -101,36 +106,72 @@ function EditKeamananLingkungan() {
}; };
return ( return (
<Box> <Box px={{ base: "sm", md: "lg" }} py="md">
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors["blue-button"]} size={30} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
</Button> <Button
</Box> variant="subtle"
<Paper bg={"white"} p={"md"} w={{ base: "100%", md: "50%" }}> onClick={() => router.back()}
<Stack gap={"xs"}> p="xs"
<Title order={3}>Edit Keamanan Lingkungan</Title> radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Keamanan Lingkungan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
<Dropzone <Dropzone
onDrop={(files) => { onDrop={(files) => {
const selectedFile = files[0]; // Ambil file pertama const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setFile(selectedFile); setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview setPreviewImage(URL.createObjectURL(selectedFile));
} }
}} }}
onReject={() => toast.error('File tidak valid.')} onReject={() => toast.error("File tidak valid.")}
maxSize={5 * 1024 ** 2} // Maks 5MB maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ "image/*": [] }}
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <Group
justify="center"
gap="xl"
mih={220}
style={{ pointerEvents: "none" }}
>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload
size={52}
color="var(--mantine-color-blue-6)"
stroke={1.5}
/>
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} /> <IconX
size={52}
color="var(--mantine-color-red-6)"
stroke={1.5}
/>
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} /> <IconPhoto
size={52}
color="var(--mantine-color-dimmed)"
stroke={1.5}
/>
</Dropzone.Idle> </Dropzone.Idle>
<div> <div>
@@ -151,15 +192,21 @@ function EditKeamananLingkungan() {
<IconImageInPicture /> <IconImageInPicture />
</Center> </Center>
)} )}
<TextInput <TextInput
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) =>
label={<Text fz={"sm"} fw={"bold"}>Judul Keamanan Lingkungan</Text>} setFormData({ ...formData, name: e.target.value })
placeholder="masukkan judul" }
label="Judul Keamanan Lingkungan"
placeholder="Masukkan judul"
required
/> />
<Box> <Box>
<Text fz={"sm"} fw={"bold"}>Deskripsi</Text> <Text fz="sm" fw="bold">
Deskripsi
</Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(htmlContent) => { onChange={(htmlContent) => {
@@ -169,7 +216,20 @@ function EditKeamananLingkungan() {
/> />
</Box> </Box>
<Button onClick={handleSubmit}>Simpan</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { Box, Button, Flex, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
@@ -36,64 +36,95 @@ function DetailKeamananLingkungan() {
if (!keamananState.findUnique.data) { if (!keamananState.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={40} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) )
} }
const data = keamananState.findUnique.data
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Back */}
<Button variant="subtle" onClick={() => router.back()}> <Button
<IconArrowBack color={colors['blue-button']} size={25} /> variant="subtle"
</Button> onClick={() => router.back()}
</Box> leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}> mb={15}
<Stack> >
<Text fz={"xl"} fw={"bold"}>Detail Keamanan Lingkungan</Text> Kembali
{keamananState.findUnique.data ? ( </Button>
<Paper key={keamananState.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}> {/* Wrapper Detail */}
<Box> <Paper
<Text fw={"bold"} fz={"lg"}>Gambar</Text> withBorder
<Image w={{ base: 150, md: 490}} src={keamananState.findUnique.data?.image?.link} alt="gambar" /> w={{ base: "100%", md: "50%" }}
</Box> bg={colors['white-1']}
<Box> p="lg"
<Text fw={"bold"} fz={"lg"}>Judul Keamanan Lingkungan</Text> radius="md"
<Text fz={"lg"}>{keamananState.findUnique.data?.name}</Text> shadow="sm"
</Box> >
<Box> <Stack gap="md">
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text> <Text fz="2xl" fw="bold" c={colors['blue-button']}>
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: keamananState.findUnique.data?.deskripsi }} /> Detail Keamanan Lingkungan
</Box> </Text>
<Flex gap={"xs"} mt={10}>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Image
w={{ base: 150, md: 490 }}
src={data?.image?.link}
alt="gambar keamanan lingkungan"
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Judul Keamanan Lingkungan</Text>
<Text fz="md" c="dimmed">{data?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data?.deskripsi }} />
</Box>
{/* Aksi */}
<Group gap="sm">
<Tooltip label="Hapus Data" withArrow position="top">
<Button <Button
color="red"
onClick={() => { onClick={() => {
if (keamananState.findUnique.data) { setSelectedId(data.id);
setSelectedId(keamananState.findUnique.data.id); setModalHapus(true);
setModalHapus(true);
}
}} }}
disabled={keamananState.delete.loading || !keamananState.findUnique.data} variant="light"
color={"red"} radius="md"
size="md"
> >
<IconX size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Data" withArrow position="top">
<Button <Button
onClick={() => { color="green"
if (keamananState.findUnique.data) { onClick={() =>
router.push(`/admin/keamanan/keamanan-lingkungan-pecalang-patwal/${keamananState.findUnique.data.id}/edit`); router.push(
} `/admin/keamanan/keamanan-lingkungan-pecalang-patwal/${data.id}/edit`
}} )
disabled={!keamananState.findUnique.data} }
color={"green"} variant="light"
radius="md"
size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Flex> </Tooltip>
</Stack> </Group>
</Paper> </Stack>
) : null} </Paper>
</Stack> </Stack>
</Paper> </Paper>
@@ -102,10 +133,10 @@ function DetailKeamananLingkungan() {
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}
onConfirm={handleHapus} onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus keamanan lingkungan ini?' text="Apakah anda yakin ingin menghapus keamanan lingkungan ini?"
/> />
</Box> </Box>
); );
} }
export default DetailKeamananLingkungan; export default DetailKeamananLingkungan;

View File

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

View File

@@ -1,26 +1,46 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; Box,
import HeaderSearch from '../../_com/header'; Button,
import JudulList from '../../_com/judulList'; Center,
import { useRouter } from 'next/navigation'; Group,
import { useProxy } from 'valtio/utils'; Pagination,
import keamananLingkunganState from '../../_state/keamanan/keamanan-lingkungan'; Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import keamananLingkunganState from '../../_state/keamanan/keamanan-lingkungan';
function KeamananLingkungan() { function KeamananLingkungan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
{/* Header Search */}
<HeaderSearch <HeaderSearch
title='Keamanan Lingkungan' title='Keamanan Lingkungan'
placeholder='pencarian' placeholder='Cari nama atau deskripsi...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
/> />
<ListKeamananLingkungan search={search} /> <ListKeamananLingkungan search={search} />
</Box> </Box>
); );
@@ -47,54 +67,94 @@ function ListKeamananLingkungan({ search }: { search: string }) {
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<JudulList {/* Judul + Tombol Tambah */}
title='List Keamanan Lingkungan' <Group justify="space-between" mb="md">
href='/admin/keamanan/keamanan-lingkungan-pecalang-patwal/create' <Title order={4}>Daftar Keamanan Lingkungan</Title>
/> <Tooltip label="Tambah Data Keamanan" withArrow>
<Table striped withTableBorder withRowBorders> <Button
<TableThead> leftSection={<IconPlus size={18} />}
<TableTr> color="blue"
<TableTh>Nama Keamanan Lingkungan</TableTh> variant="light"
<TableTh>Deskripsi</TableTh> onClick={() =>
<TableTh>Detail</TableTh> router.push('/admin/keamanan/keamanan-lingkungan-pecalang-patwal/create')
</TableTr> }
</TableThead> >
<TableTbody> Tambah Baru
{filteredData.map((item) => ( </Button>
<TableTr key={item.id}> </Tooltip>
<TableTd> </Group>
<Box w={180}>
<Text fz={"md"} truncate={"end"} lineClamp={1}>{item.name}</Text> {/* Tabel */}
</Box> <Box style={{ overflowX: "auto" }}>
</TableTd> <Table highlightOnHover>
<TableTd> <TableThead>
<Box w={250}> <TableTr>
<Text fz={"md"} truncate={"end"} lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <TableTh>Nama</TableTh>
</Box> <TableTh>Deskripsi</TableTh>
</TableTd> <TableTh>Aksi</TableTh>
<TableTd>
<Button onClick={() => router.push(`/admin/keamanan/keamanan-lingkungan-pecalang-patwal/${item.id}`)}>
<IconDeviceImac size={20} />
</Button>
</TableTd>
</TableTr> </TableTr>
))} </TableThead>
</TableTbody> <TableTbody>
</Table> {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" truncate lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/keamanan/keamanan-lingkungan-pecalang-patwal/${item.id}`)}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data keamanan lingkungan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
my={"md"} mt="md"
mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -1,26 +1,45 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import JudulList from '../../_com/judulList';
import polsekTerdekat from '../../_state/keamanan/polsek-terdekat'; import polsekTerdekat from '../../_state/keamanan/polsek-terdekat';
import { useState } from 'react';
function PolsekTerdekat() { function PolsekTerdekat() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='Polsek Terdekat' title='Polsek Terdekat'
placeholder='pencarian' placeholder='Cari nama atau alamat...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
/> />
<ListPolsekTerdekat search={search} /> <ListPolsekTerdekat search={search} />
</Box> </Box>
); );
@@ -47,60 +66,90 @@ function ListPolsekTerdekat({ search }: { search: string }) {
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<JudulList <Group justify="space-between" mb="md">
title='List Polsek Terdekat' <Title order={4}>Daftar Polsek Terdekat</Title>
href='/admin/keamanan/polsek-terdekat/create' <Tooltip label="Tambah Polsek" withArrow>
/> <Button
<Table striped withTableBorder withRowBorders> leftSection={<IconPlus size={18} />}
<TableThead> color="blue"
<TableTr> variant="light"
<TableTh>Nama Polsek Terdekat</TableTh> onClick={() => router.push('/admin/keamanan/polsek-terdekat/create')}
<TableTh>Jarak Polsek</TableTh> >
<TableTh>Alamat</TableTh> Tambah Baru
<TableTh>Detail</TableTh> </Button>
</TableTr> </Tooltip>
</TableThead> </Group>
<TableTbody>
{filteredData.map((item) => ( <Box style={{ overflowX: "auto" }}>
<TableTr key={item.id}> <Table highlightOnHover>
<TableTd> <TableThead>
<Box w={180}> <TableTr>
<Text fz='md' truncate={"end"} lineClamp={1}>{item.nama}</Text> <TableTh>Nama Polsek</TableTh>
</Box> <TableTh>Jarak</TableTh>
</TableTd> <TableTh>Alamat</TableTh>
<TableTd> <TableTh>Aksi</TableTh>
<Box w={180}>
<Text fz='md' truncate={"end"} lineClamp={1}>{item.jarakKeDesa}</Text>
</Box>
</TableTd>
<TableTd>
<Box w={250}>
<Text fz='md' truncate={"end"} lineClamp={1}>{item.alamat}</Text>
</Box>
</TableTd>
<TableTd>
<Button onClick={() => router.push(`/admin/keamanan/polsek-terdekat/${item.id}`)}>
<IconDeviceImac size={20} />
</Button>
</TableTd>
</TableTr> </TableTr>
))} </TableThead>
</TableTbody> <TableTbody>
</Table> {filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fw={500} truncate="end" lineClamp={1}>
{item.nama}
</Text>
</TableTd>
<TableTd>{item.jarakKeDesa}</TableTd>
<TableTd>{item.alamat}</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/keamanan/polsek-terdekat/${item.id}`)}
>
<IconDeviceImac size={18} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text color="dimmed">
Tidak ada data Polsek yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
my="md" mt="md"
mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -1,77 +1,140 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconActivity, IconBuildingHospital, IconCalendarEvent, IconGauge, IconNotes } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
function LayoutTabs({ children }: { children: React.ReactNode }) { function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter();
const pathname = usePathname() const pathname = usePathname();
const tabs = [
{
label: "Presentase Kelahiran & Kematian",
value: "presentasekelahiran&kematian",
href: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian"
},
{
label: "Grafik Hasil Kepuasan Masyarakat Terhadap Pelayanan Publik",
value: "grafikhasilkepuasan",
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan"
},
{
label: "Fasilitas Kesehatan",
value: "fasilitaskesehatan",
href: "/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan"
},
{
label: "Jadwal Kegiatan",
value: "jadwalkegiatan",
href: "/admin/kesehatan/data-kesehatan-warga/jadwal_kegiatan"
},
{
label: "Artikel Kesehatan",
value: "artikelkesehatan",
href: "/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan"
},
];
const curentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(curentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => { const tabs = [
const match = tabs.find(tab => tab.href === pathname) {
if (match) { label: "Presentase Kelahiran & Kematian",
setActiveTab(match.value) value: "presentasekelahiran&kematian",
} href: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian",
}, [pathname]) icon: <IconActivity size={18} stroke={1.8} />,
tooltip: "Lihat data kelahiran dan kematian"
},
{
label: "Grafik Hasil Kepuasan Masyarakat",
value: "grafikhasilkepuasan",
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan",
icon: <IconGauge size={18} stroke={1.8} />,
tooltip: "Grafik kepuasan masyarakat terhadap pelayanan"
},
{
label: "Fasilitas Kesehatan",
value: "fasilitaskesehatan",
href: "/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan",
icon: <IconBuildingHospital size={18} stroke={1.8} />,
tooltip: "Data fasilitas kesehatan desa"
},
{
label: "Jadwal Kegiatan",
value: "jadwalkegiatan",
href: "/admin/kesehatan/data-kesehatan-warga/jadwal_kegiatan",
icon: <IconCalendarEvent size={18} stroke={1.8} />,
tooltip: "Atur jadwal kegiatan kesehatan"
},
{
label: "Artikel Kesehatan",
value: "artikelkesehatan",
href: "/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan",
icon: <IconNotes size={18} stroke={1.8} />,
tooltip: "Artikel & informasi seputar kesehatan"
},
];
return (
<Stack> const currentTab = tabs.find(tab => tab.href === pathname);
<Title order={3}>Data Kesehatan Warga</Title> const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}>
<TabsList p={"xs"} bg={"#BBC8E7FF"}>
{tabs.map((e, i) => ( const handleTabChange = (value: string | null) => {
<TabsTab key={i} value={e.value}>{e.label}</TabsTab> const tab = tabs.find(t => t.value === value);
))} if (tab) {
</TabsList> router.push(tab.href);
{tabs.map((e, i) => ( }
<TabsPanel key={i} value={e.value}> setActiveTab(value);
{/* Konten dummy, bisa diganti tergantung routing */} };
<></>
</TabsPanel>
))} useEffect(() => {
</Tabs> const match = tabs.find(tab => tab.href === pathname);
{children} if (match) {
</Stack> setActiveTab(match.value);
); }
}, [pathname]);
return (
<Stack gap="lg">
<Title order={2} fw={700} style={{ color: "#1A1B1E" }}>
Data Kesehatan Warga
</Title>
<Tabs
color={colors['blue-button']}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
}}
>
{tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))}
</TabsList>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{children}
</TabsPanel>
))}
</Tabs>
</Stack>
);
} }
export default LayoutTabs; export default LayoutTabs;

View File

@@ -4,7 +4,17 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan'; import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -14,29 +24,12 @@ import { useProxy } from 'valtio/utils';
interface ArtikelKesehatanFormBase { interface ArtikelKesehatanFormBase {
title: string; title: string;
content: string; content: string;
introduction: { introduction: { content: string };
content: string; symptom: { title: string; content: string };
}; prevention: { title: string; content: string };
symptom: { firstAid: { title: string; content: string };
title: string; mythVsFact: { title: string; mitos: string; fakta: string };
content: string; doctorSign: { content: string };
};
prevention: {
title: string;
content: string;
};
firstAid: {
title: string;
content: string;
};
mythVsFact: {
title: string;
mitos: string;
fakta: string;
};
doctorSign: {
content: string;
};
} }
function EditArtikelKesehatan() { function EditArtikelKesehatan() {
@@ -47,29 +40,27 @@ function EditArtikelKesehatan() {
const [formData, setFormData] = useState<ArtikelKesehatanFormBase>({ const [formData, setFormData] = useState<ArtikelKesehatanFormBase>({
title: stateArtikelKesehatan.edit.form.title || '', title: stateArtikelKesehatan.edit.form.title || '',
content: stateArtikelKesehatan.edit.form.content || '', content: stateArtikelKesehatan.edit.form.content || '',
introduction: { introduction: { content: stateArtikelKesehatan.edit.form.introduction?.content || '' },
content: stateArtikelKesehatan.edit.form.introduction?.content || '',
},
symptom: { symptom: {
title: stateArtikelKesehatan.edit.form.symptom?.title || '', title: stateArtikelKesehatan.edit.form.symptom?.title || '',
content: stateArtikelKesehatan.edit.form.symptom?.content || '', content: stateArtikelKesehatan.edit.form.symptom?.content || ''
}, },
prevention: { prevention: {
title: stateArtikelKesehatan.edit.form.prevention?.title || '', title: stateArtikelKesehatan.edit.form.prevention?.title || '',
content: stateArtikelKesehatan.edit.form.prevention?.content || '', content: stateArtikelKesehatan.edit.form.prevention?.content || ''
}, },
firstAid: { firstAid: {
title: stateArtikelKesehatan.edit.form.firstAid?.title || '', title: stateArtikelKesehatan.edit.form.firstAid?.title || '',
content: stateArtikelKesehatan.edit.form.firstAid?.content || '', content: stateArtikelKesehatan.edit.form.firstAid?.content || ''
}, },
mythVsFact: { mythVsFact: {
title: stateArtikelKesehatan.edit.form.mythVsFact?.title || '', title: stateArtikelKesehatan.edit.form.mythVsFact?.title || '',
mitos: stateArtikelKesehatan.edit.form.mythVsFact?.mitos || '', mitos: stateArtikelKesehatan.edit.form.mythVsFact?.mitos || '',
fakta: stateArtikelKesehatan.edit.form.mythVsFact?.fakta || '', fakta: stateArtikelKesehatan.edit.form.mythVsFact?.fakta || ''
}, },
doctorSign: { doctorSign: {
content: stateArtikelKesehatan.edit.form.doctorSign?.content || '', content: stateArtikelKesehatan.edit.form.doctorSign?.content || ''
}, }
}); });
useEffect(() => { useEffect(() => {
@@ -84,29 +75,27 @@ function EditArtikelKesehatan() {
setFormData({ setFormData({
title: form.title, title: form.title,
content: form.content, content: form.content,
introduction: { introduction: { content: form.introduction?.content || '' },
content: form.introduction?.content || '',
},
symptom: { symptom: {
title: form.symptom?.title || '', title: form.symptom?.title || '',
content: form.symptom?.content || '', content: form.symptom?.content || ''
}, },
prevention: { prevention: {
title: form.prevention?.title || '', title: form.prevention?.title || '',
content: form.prevention?.content || '', content: form.prevention?.content || ''
}, },
firstAid: { firstAid: {
title: form.firstAid?.title || '', title: form.firstAid?.title || '',
content: form.firstAid?.content || '', content: form.firstAid?.content || ''
}, },
mythVsFact: { mythVsFact: {
title: form.mythVsFact?.title || '', title: form.mythVsFact?.title || '',
mitos: form.mythVsFact?.mitos || '', mitos: form.mythVsFact?.mitos || '',
fakta: form.mythVsFact?.fakta || '', fakta: form.mythVsFact?.fakta || ''
}, },
doctorSign: { doctorSign: {
content: form.doctorSign?.content || '', content: form.doctorSign?.content || ''
}, }
}); });
} }
} catch (error) { } catch (error) {
@@ -119,34 +108,7 @@ function EditArtikelKesehatan() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
stateArtikelKesehatan.edit.form = { stateArtikelKesehatan.edit.form = { ...formData };
...stateArtikelKesehatan.edit.form,
title: formData.title,
content: formData.content,
introduction: {
content: formData.introduction.content,
},
symptom: {
title: formData.symptom.title,
content: formData.symptom.content,
},
prevention: {
title: formData.prevention.title,
content: formData.prevention.content,
},
firstAid: {
title: formData.firstAid.title,
content: formData.firstAid.content,
},
mythVsFact: {
title: formData.mythVsFact.title,
mitos: formData.mythVsFact.mitos,
fakta: formData.mythVsFact.fakta,
},
doctorSign: {
content: formData.doctorSign.content,
},
};
const success = await stateArtikelKesehatan.edit.submit(); const success = await stateArtikelKesehatan.edit.submit();
if (success) { if (success) {
toast.success("Artikel kesehatan berhasil diperbarui!"); toast.success("Artikel kesehatan berhasil diperbarui!");
@@ -157,214 +119,196 @@ function EditArtikelKesehatan() {
toast.error(error instanceof Error ? error.message : "Gagal memperbarui data artikel kesehatan"); toast.error(error instanceof Error ? error.message : "Gagal memperbarui data artikel kesehatan");
} }
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button onClick={() => router.back()} variant="subtle" color="blue"> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
</Button> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Box> <IconArrowBack color={colors['blue-button']} size={24} />
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> </Button>
<Stack gap="xs"> </Tooltip>
<Title order={3}>Edit Artikel Kesehatan</Title> <Title order={4} ml="sm" c="dark">
Edit Artikel Kesehatan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label="Judul"
placeholder="masukkan judul" placeholder="Masukkan judul artikel"
value={formData.title} value={formData.title}
onChange={(e) => { onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
setFormData(prev => ({ required
...prev,
title: e.target.value
}));
}}
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Deskripsi</Text>} label="Deskripsi"
placeholder="masukkan deskripsi" placeholder="Masukkan deskripsi artikel"
value={formData.content} value={formData.content}
onChange={(e) => { onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
setFormData(prev => ({ required
...prev,
content: e.target.value
}));
}}
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Pendahuluan</Text>} label="Pendahuluan"
placeholder="masukkan pendahuluan" placeholder="Masukkan pendahuluan"
value={formData.introduction.content} value={formData.introduction.content}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
introduction: { introduction: { ...prev.introduction, content: e.target.value }
...prev.introduction, }))
content: e.target.value }
}
}));
}}
/> />
{/* Gejala */}
<Box> <Box>
<Text fz="md" fw="bold">Gejala</Text> <Text fw="bold">Gejala</Text>
<Stack gap="xs"> <Stack gap="xs">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label="Judul Gejala"
placeholder="masukkan judul gejala penyakit" placeholder="Masukkan judul gejala"
value={formData.symptom.title} value={formData.symptom.title}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
symptom: { symptom: { ...prev.symptom, title: e.target.value }
...prev.symptom, }))
title: e.target.value }
} />
})); <EditEditor
}} value={formData.symptom.content}
onChange={(e) =>
setFormData(prev => ({
...prev,
symptom: { ...prev.symptom, content: e }
}))
}
/> />
<Box>
<Text fz="sm" fw="bold">Deskripsi Gejala</Text>
<EditEditor
value={formData.symptom.content}
onChange={(e) => {
setFormData(prev => ({
...prev,
symptom: {
...prev.symptom,
content: e
}
}));
}}
/>
</Box>
</Stack> </Stack>
</Box> </Box>
{/* Pencegahan */}
<Box> <Box>
<Text fz="md" fw="bold">Pencegahan</Text> <Text fw="bold">Pencegahan</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label="Judul"
placeholder="masukkan judul"
value={formData.prevention.title} value={formData.prevention.title}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
prevention: { prevention: { ...prev.prevention, title: e.target.value }
...prev.prevention, }))
title: e.target.value }
}
}));
}}
/> />
<EditEditor <EditEditor
value={formData.prevention.content} value={formData.prevention.content}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
prevention: { prevention: { ...prev.prevention, content: e }
...prev.prevention, }))
content: e }
}
}));
}}
/> />
</Box> </Box>
{/* Pertolongan Pertama */}
<Box> <Box>
<Text fz="md" fw="bold">Pertolongan Pertama</Text> <Text fw="bold">Pertolongan Pertama</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label="Judul"
placeholder="masukkan judul"
value={formData.firstAid.title} value={formData.firstAid.title}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
firstAid: { firstAid: { ...prev.firstAid, title: e.target.value }
...prev.firstAid, }))
title: e.target.value }
}
}));
}}
/> />
<EditEditor <EditEditor
value={formData.firstAid.content} value={formData.firstAid.content}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
firstAid: { firstAid: { ...prev.firstAid, content: e }
...prev.firstAid, }))
content: e }
}
}));
}}
/> />
</Box> </Box>
{/* Mitos vs Fakta */}
<Box> <Box>
<Text fz="md" fw="bold">Mitos dan Fakta</Text> <Text fw="bold">Mitos vs Fakta</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label="Judul"
placeholder="masukkan judul"
value={formData.mythVsFact.title} value={formData.mythVsFact.title}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
mythVsFact: { mythVsFact: { ...prev.mythVsFact, title: e.target.value }
...prev.mythVsFact, }))
title: e.target.value }
} />
})); <Text fw="500">Mitos</Text>
}} <EditEditor
value={formData.mythVsFact.mitos}
onChange={(e) =>
setFormData(prev => ({
...prev,
mythVsFact: { ...prev.mythVsFact, mitos: e }
}))
}
/>
<Text fw="500">Fakta</Text>
<EditEditor
value={formData.mythVsFact.fakta}
onChange={(e) =>
setFormData(prev => ({
...prev,
mythVsFact: { ...prev.mythVsFact, fakta: e }
}))
}
/> />
<Box>
<Text>
Mitos
</Text>
<EditEditor
value={formData.mythVsFact.mitos}
onChange={(e) => {
setFormData(prev => ({
...prev,
mythVsFact: {
...prev.mythVsFact,
mitos: e
}
}));
}}
/>
</Box>
<Box>
<Text>
Fakta
</Text>
<EditEditor
value={formData.mythVsFact.fakta}
onChange={(e) => {
setFormData(prev => ({
...prev,
mythVsFact: {
...prev.mythVsFact,
fakta: e
}
}));
}}
/>
</Box>
</Box> </Box>
{/* Kapan harus ke dokter */}
<Box> <Box>
<Text fz="md" fw="bold">Kapan Harus Ke Dokter</Text> <Text fw="bold">Kapan Harus Ke Dokter</Text>
<EditEditor <EditEditor
value={formData.doctorSign.content} value={formData.doctorSign.content}
onChange={(e) => { onChange={(e) =>
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
doctorSign: { doctorSign: { ...prev.doctorSign, content: e }
...prev.doctorSign, }))
content: e }
}
}));
}}
/> />
</Box> </Box>
<Button onClick={handleSubmit} bg={colors['blue-button']}>
Simpan {/* Save button */}
</Button> <Group justify="right">
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)'
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -2,134 +2,181 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan'; import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailArtikelKesehatan() { function DetailArtikelKesehatan() {
const params = useParams() const params = useParams();
const router = useRouter(); const router = useRouter();
const stateArtikelKesehatan = useProxy(artikelKesehatanState) const state = useProxy(artikelKesehatanState);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => { useShallowEffect(() => {
stateArtikelKesehatan.findUnique.load(params?.id as string) state.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
stateArtikelKesehatan.delete.byId(selectedId) state.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan") router.push('/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan');
} }
} };
if (!stateArtikelKesehatan.findUnique.data) { if (!state.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = state.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Back */}
<Button variant="subtle" onClick={() => router.back()}> <Button
<IconArrowBack color={colors['blue-button']} size={25} /> variant="subtle"
</Button> onClick={() => router.back()}
</Box> leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
<Paper w={{ base: "100%", md: "50%" }} bg={colors['white-1']} p={'md'}> mb={15}
<Stack> >
<Text fz={"xl"} fw={"bold"}>Detail Artikel Kesehatan</Text> Kembali
{stateArtikelKesehatan.findUnique.data ? ( </Button>
<Paper bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}> {/* Wrapper Detail */}
<Box> <Paper
<Text fz={"lg"} fw={"bold"}>Judul</Text> withBorder
<Text fz={"md"}>{stateArtikelKesehatan.findUnique.data.title}</Text> w={{ base: '100%', md: '50%' }}
</Box> bg={colors['white-1']}
<Box> p="lg"
<Text fz={"lg"} fw={"bold"}>Deskripsi</Text> radius="md"
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.content }} /> shadow="sm"
</Box> >
<Box> <Stack gap="md">
<Text fz={"lg"} fw={"bold"}>Pendahuluan</Text> <Text fz="2xl" fw="bold" c={colors['blue-button']}>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.introduction.content }} /> Detail Artikel Kesehatan
</Box> </Text>
<Box>
<Stack gap={"xs"}> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Text fz={"lg"} fw={"bold"}>Gejala</Text> <Stack gap="sm">
<Text fz={"md"} fw={"bold"}>Judul Gejala</Text> {/* Judul */}
<Text fz={"md"}>{stateArtikelKesehatan.findUnique.data.symptom.title}</Text> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi Gejala</Text> <Text fz="lg" fw="bold">Judul</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.symptom.content }} /> <Text fz="md" c="dimmed">{data.title}</Text>
</Stack> </Box>
</Box>
<Box> {/* Deskripsi */}
<Stack gap={"xs"}> <Box>
<Text fz={"lg"} fw={"bold"}>Pencegahan</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz={"md"} fw={"bold"}>Judul Pencegahan</Text> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.content }} />
<Text fz={"md"}>{stateArtikelKesehatan.findUnique.data.prevention.title}</Text> </Box>
<Text fz={"md"} fw={"bold"}>Deskripsi Pencegahan</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.prevention.content }} /> {/* Pendahuluan */}
</Stack> <Box>
</Box> <Text fz="lg" fw="bold">Pendahuluan</Text>
<Box> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.introduction?.content }} />
<Stack gap={"xs"}> </Box>
<Text fz={"lg"} fw={"bold"}>Pertolongan Pertama</Text>
<Text fz={"md"} fw={"bold"}>Judul Pertolongan Pertama</Text> {/* Gejala */}
<Text fz={"md"}>{stateArtikelKesehatan.findUnique.data.firstaid.title}</Text> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi Pertolongan Pertama</Text> <Text fz="lg" fw="bold">Gejala</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.firstaid.content }} /> <Text fz="md" fw="bold">Judul</Text>
</Stack> <Text fz="md" c="dimmed">{data.symptom?.title}</Text>
</Box> <Text fz="md" fw="bold">Deskripsi</Text>
<Box> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.symptom?.content }} />
<Stack gap={"xs"}> </Box>
<Text fz={"lg"} fw={"bold"}>Mitos dan Fakta</Text>
<Text fz={"md"} fw={"bold"}>Judul Mitos dan Fakta</Text> {/* Pencegahan */}
<Text fz={"md"}>{stateArtikelKesehatan.findUnique.data.mythvsfact.title}</Text> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi Mitos</Text> <Text fz="lg" fw="bold">Pencegahan</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.mythvsfact.mitos }} /> <Text fz="md" fw="bold">Judul</Text>
<Text fz={"md"} fw={"bold"}>Deskripsi Fakta</Text> <Text fz="md" c="dimmed">{data.prevention?.title}</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.mythvsfact.fakta }} /> <Text fz="md" fw="bold">Deskripsi</Text>
</Stack> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.prevention?.content }} />
</Box> </Box>
<Box>
<Stack gap={"xs"}> {/* Pertolongan Pertama */}
<Text fz={"lg"} fw={"bold"}>Kapan Harus ke Dokter</Text> <Box>
<Text fz={"md"} fw={"bold"}>Deskripsi Kapan Harus ke Dokter</Text> <Text fz="lg" fw="bold">Pertolongan Pertama</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateArtikelKesehatan.findUnique.data.doctorsign.content }} /> <Text fz="md" fw="bold">Judul</Text>
</Stack> <Text fz="md" c="dimmed">{data.firstaid?.title}</Text>
</Box> <Text fz="md" fw="bold">Deskripsi</Text>
<Box> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.firstaid?.content }} />
<Flex gap={"xs"}> </Box>
<Button color="red" onClick={() => {
if (stateArtikelKesehatan.findUnique.data) { {/* Mitos vs Fakta */}
setSelectedId(stateArtikelKesehatan.findUnique.data.id) <Box>
setModalHapus(true) <Text fz="lg" fw="bold">Mitos dan Fakta</Text>
} <Text fz="md" fw="bold">Judul</Text>
}}> <Text fz="md" c="dimmed">{data.mythvsfact?.title}</Text>
<IconX size={20} /> <Text fz="md" fw="bold">Mitos</Text>
</Button> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.mythvsfact?.mitos }} />
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/${stateArtikelKesehatan.findUnique.data?.id}/edit`)} color="green"> <Text fz="md" fw="bold">Fakta</Text>
<IconEdit size={20} /> <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.mythvsfact?.fakta }} />
</Button> </Box>
</Flex>
</Box> {/* Kapan ke Dokter */}
</Stack> <Box>
</Paper> <Text fz="lg" fw="bold">Kapan Harus ke Dokter</Text>
) : null} <Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.doctorsign?.content }} />
</Box>
{/* Aksi */}
<Group gap="sm">
<Tooltip label="Hapus Artikel" withArrow position="top">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Artikel" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>
</Stack> </Stack>
</Paper> </Paper>
{/* Modal Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}

View File

@@ -2,101 +2,129 @@
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan'; import artikelKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/artikelKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateArtikelKesehatan() { function CreateArtikelKesehatan() {
const stateArtikelKesehatan = useProxy(artikelKesehatanState) const stateArtikelKesehatan = useProxy(artikelKesehatanState);
const router = useRouter(); const router = useRouter();
const resetForm = () => { const resetForm = () => {
stateArtikelKesehatan.create.form = { stateArtikelKesehatan.create.form = {
title: "", title: '',
content: "", content: '',
introduction: { introduction: {
content: "", content: '',
}, },
symptom: { symptom: {
title: "", title: '',
content: "", content: '',
}, },
prevention: { prevention: {
title: "", title: '',
content: "", content: '',
}, },
firstAid: { firstAid: {
title: "", title: '',
content: "", content: '',
}, },
mythVsFact: { mythVsFact: {
title: "", title: '',
mitos: "", mitos: '',
fakta: "", fakta: '',
}, },
doctorSign: { doctorSign: {
content: "" content: '',
} },
}; };
}; };
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await stateArtikelKesehatan.create.submit(); await stateArtikelKesehatan.create.submit();
toast.success('Data berhasil disimpan');
toast.success("Data berhasil disimpan");
resetForm(); resetForm();
// After successful submission, redirect to the list page
router.push('/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan'); router.push('/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan');
} };
return ( return (
<Box component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
</Button> <Button
</Box> variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Artikel Kesehatan
</Title>
</Group>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> {/* Form */}
<Stack gap="xs"> <Paper
<Title order={3}>Create Artikel Kesehatan</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label={"Judul"}
placeholder="masukkan judul" placeholder="Masukkan judul"
value={stateArtikelKesehatan.create.form.title} value={stateArtikelKesehatan.create.form.title}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.title = e.target.value; stateArtikelKesehatan.create.form.title = e.target.value;
}} }}
required
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Deskripsi</Text>} label={"Deskripsi"}
placeholder="masukkan deskripsi" placeholder="Masukkan deskripsi"
value={stateArtikelKesehatan.create.form.content} value={stateArtikelKesehatan.create.form.content}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.content = e.target.value; stateArtikelKesehatan.create.form.content = e.target.value;
}} }}
required
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Pendahuluan</Text>} label={"Pendahuluan"}
placeholder="masukkan pendahuluan" placeholder="Masukkan pendahuluan"
required
value={stateArtikelKesehatan.create.form.introduction.content} value={stateArtikelKesehatan.create.form.introduction.content}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.introduction.content = e.target.value; stateArtikelKesehatan.create.form.introduction.content = e.target.value;
}} }}
/> />
{/* Gejala */}
<Box> <Box>
<Text fz="md" fw="bold">Gejala</Text> <Text fz="md" fw="bold">Gejala</Text>
<Stack gap="xs"> <Stack gap="sm">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label={"Judul Gejala"}
placeholder="masukkan judul gejala penyakit" required
placeholder="Masukkan judul gejala penyakit"
value={stateArtikelKesehatan.create.form.symptom.title} value={stateArtikelKesehatan.create.form.symptom.title}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.symptom.title = e.target.value; stateArtikelKesehatan.create.form.symptom.title = e.target.value;
@@ -114,54 +142,62 @@ function CreateArtikelKesehatan() {
</Stack> </Stack>
</Box> </Box>
<Box> {/* Pencegahan */}
<Stack gap="xs">
<Text fz="md" fw="bold">Pencegahan</Text> <Text fz="md" fw="bold">Pencegahan</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label={"Judul Pencegahan"}
placeholder="masukkan judul" required
placeholder="Masukkan judul"
value={stateArtikelKesehatan.create.form.prevention.title} value={stateArtikelKesehatan.create.form.prevention.title}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.prevention.title = e.target.value; stateArtikelKesehatan.create.form.prevention.title = e.target.value;
}} }}
/> />
<Text fz="sm">Deskripsi Pencegahan</Text>
<CreateEditor <CreateEditor
value={stateArtikelKesehatan.create.form.prevention.content} value={stateArtikelKesehatan.create.form.prevention.content}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.prevention.content = e; stateArtikelKesehatan.create.form.prevention.content = e;
}} }}
/> />
</Box> </Stack>
<Box>
{/* Pertolongan Pertama */}
<Stack gap={"xs"}>
<Text fz="md" fw="bold">Pertolongan Pertama</Text> <Text fz="md" fw="bold">Pertolongan Pertama</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label={"Judul Pertolongan Pertama"}
placeholder="masukkan judul" required
placeholder="Masukkan judul"
value={stateArtikelKesehatan.create.form.firstAid.title} value={stateArtikelKesehatan.create.form.firstAid.title}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.firstAid.title = e.target.value; stateArtikelKesehatan.create.form.firstAid.title = e.target.value;
}} }}
/> />
<Text fz="sm">Deskripsi Pertolongan Pertama</Text>
<CreateEditor <CreateEditor
value={stateArtikelKesehatan.create.form.firstAid.content} value={stateArtikelKesehatan.create.form.firstAid.content}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.firstAid.content = e; stateArtikelKesehatan.create.form.firstAid.content = e;
}} }}
/> />
</Box> </Stack>
{/* Mitos vs Fakta */}
<Box> <Box>
<Text fz="md" fw="bold">Mitos dan Fakta</Text> <Text fz="md" fw="bold">Mitos dan Fakta</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Judul</Text>} label={"Judul Mitos dan Fakta"}
placeholder="masukkan judul" required
placeholder="Masukkan judul"
value={stateArtikelKesehatan.create.form.mythVsFact.title} value={stateArtikelKesehatan.create.form.mythVsFact.title}
onChange={(e) => { onChange={(e) => {
stateArtikelKesehatan.create.form.mythVsFact.title = e.target.value; stateArtikelKesehatan.create.form.mythVsFact.title = e.target.value;
}} }}
/> />
<Box> <Box mt="sm">
<Text> <Text fz="sm" fw="bold">Mitos</Text>
Mitos
</Text>
<CreateEditor <CreateEditor
value={stateArtikelKesehatan.create.form.mythVsFact.mitos} value={stateArtikelKesehatan.create.form.mythVsFact.mitos}
onChange={(e) => { onChange={(e) => {
@@ -169,10 +205,8 @@ function CreateArtikelKesehatan() {
}} }}
/> />
</Box> </Box>
<Box> <Box mt="sm">
<Text> <Text fz="sm" fw="bold">Fakta</Text>
Fakta
</Text>
<CreateEditor <CreateEditor
value={stateArtikelKesehatan.create.form.mythVsFact.fakta} value={stateArtikelKesehatan.create.form.mythVsFact.fakta}
onChange={(e) => { onChange={(e) => {
@@ -181,8 +215,10 @@ function CreateArtikelKesehatan() {
/> />
</Box> </Box>
</Box> </Box>
{/* Kapan Harus ke Dokter */}
<Box> <Box>
<Text fz="md" fw="bold">Kapan Harus Ke Dokter</Text> <Text fz="md" fw="bold">Kapan Harus ke Dokter</Text>
<CreateEditor <CreateEditor
value={stateArtikelKesehatan.create.form.doctorSign.content} value={stateArtikelKesehatan.create.form.doctorSign.content}
onChange={(e) => { onChange={(e) => {
@@ -191,9 +227,21 @@ function CreateArtikelKesehatan() {
/> />
</Box> </Box>
<Button onClick={handleSubmit} bg={colors['blue-button']}> {/* Submit Button */}
Simpan <Group justify="right">
</Button> <Button
type="submit"
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,99 +1,164 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Paper,
Pagination,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import artikelKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/artikelKesehatan'; import artikelKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/artikelKesehatan';
import { useState } from 'react'; import { useState } from 'react';
function ArtikelKesehatan() { function ArtikelKesehatan() {
const router = useRouter();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
{/* Tombol Back */}
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors["blue-button"]} size={25} />
</Button>
</Box>
{/* Header Search */}
<HeaderSearch <HeaderSearch
title='Artikel Kesehatan' title='Artikel Kesehatan'
placeholder='pencarian' placeholder='Cari judul atau konten...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
/> />
<ListArtikelKesehatan search={search} /> <ListArtikelKesehatan search={search} />
</Box> </Box>
); );
} }
function ListArtikelKesehatan({ search }: { search: string }) { function ListArtikelKesehatan({ search }: { search: string }) {
const stateArtikelKesehatan = useProxy(artikelKesehatanState) const stateArtikel = useProxy(artikelKesehatanState);
const router = useRouter(); const router = useRouter();
const { data, page, totalPages, loading, load } = stateArtikel.findMany;
useShallowEffect(() => { useShallowEffect(() => {
stateArtikelKesehatan.findMany.load() load(page, 10, search);
}, []) }, [page, search]);
const filteredData = (stateArtikelKesehatan.findMany.data || []).filter(item => { const filteredData = data || [];
const keyword = search.toLowerCase();
if (loading || !data) {
return ( return (
item.title.toLowerCase().includes(keyword) || <Stack py={10}>
item.content.toLowerCase().includes(keyword) <Skeleton height={600} radius="md" />
</Stack>
); );
});
if (!stateArtikelKesehatan.findMany.data) {
return (
<Box py={10}>
<Skeleton h={500} />
</Box>
)
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Artikel Kesehatan' <Title order={4}>Daftar Artikel Kesehatan</Title>
href='/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/create' <Tooltip label="Tambah Artikel Kesehatan" withArrow>
/> <Button
<Box style={{ overflowX: "auto" }}> leftSection={<IconPlus size={18} />}
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> color="blue"
<TableThead> variant="light"
<TableTr> onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/create')}
<TableTh>Judul</TableTh> >
<TableTh>Content</TableTh> Tambah Baru
<TableTh>Detail</TableTh> </Button>
</TableTr> </Tooltip>
</TableThead> </Group>
<TableTbody>
{filteredData.map((item) => ( {/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Judul</TableTh>
<TableTh>Konten</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Box w={100}> <Text fw={500} truncate="end" lineClamp={1}>
<Text truncate={'end'} lineClamp={1} fz={'h5'}>{item.title}</Text> {item.title}
</Box> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={200}> <Text truncate fz="sm" c="dimmed" lineClamp={1}>
<Text truncate={'end'} lineClamp={1} fz={'h5'}>{item.content}</Text> {item.content}
</Box> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/${item.id}`)}> <Button
<IconDeviceImacCog size={25} /> variant="light"
color="blue"
onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/artikel_kesehatan/${item.id}`)}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={3}>
</Stack> <Center py={20}>
<Text color="dimmed">Tidak ada artikel yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<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> </Box>
) );
} }
export default ArtikelKesehatan; export default ArtikelKesehatan;

View File

@@ -4,7 +4,17 @@
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -18,20 +28,14 @@ interface FasilitasKesehatanFormBase {
alamat: string; alamat: string;
jamOperasional: string; jamOperasional: string;
}; };
layananUnggulan: { layananUnggulan: { content: string };
content: string;
};
dokterdanTenagaMedis: { dokterdanTenagaMedis: {
name: string; name: string;
specialist: string; specialist: string;
jadwal: string; jadwal: string;
}; };
fasilitasPendukung: { fasilitasPendukung: { content: string };
content: string; prosedurPendaftaran: { content: string };
};
prosedurPendaftaran: {
content: string;
};
tarifDanLayanan: { tarifDanLayanan: {
layanan: string; layanan: string;
tarif: string; tarif: string;
@@ -86,20 +90,14 @@ function EditFasilitasKesehatan() {
alamat: form.informasiUmum?.alamat || '', alamat: form.informasiUmum?.alamat || '',
jamOperasional: form.informasiUmum?.jamOperasional || '', jamOperasional: form.informasiUmum?.jamOperasional || '',
}, },
layananUnggulan: { layananUnggulan: { content: form.layananUnggulan?.content || '' },
content: form.layananUnggulan?.content || '',
},
dokterdanTenagaMedis: { dokterdanTenagaMedis: {
name: form.dokterdanTenagaMedis?.name || '', name: form.dokterdanTenagaMedis?.name || '',
specialist: form.dokterdanTenagaMedis?.specialist || '', specialist: form.dokterdanTenagaMedis?.specialist || '',
jadwal: form.dokterdanTenagaMedis?.jadwal || '', jadwal: form.dokterdanTenagaMedis?.jadwal || '',
}, },
fasilitasPendukung: { fasilitasPendukung: { content: form.fasilitasPendukung?.content || '' },
content: form.fasilitasPendukung?.content || '', prosedurPendaftaran: { content: form.prosedurPendaftaran?.content || '' },
},
prosedurPendaftaran: {
content: form.prosedurPendaftaran?.content || '',
},
tarifDanLayanan: { tarifDanLayanan: {
layanan: form.tarifDanLayanan?.layanan || '', layanan: form.tarifDanLayanan?.layanan || '',
tarif: form.tarifDanLayanan?.tarif || '', tarif: form.tarifDanLayanan?.tarif || '',
@@ -118,30 +116,7 @@ function EditFasilitasKesehatan() {
try { try {
stateFasilitasKesehatan.edit.form = { stateFasilitasKesehatan.edit.form = {
...stateFasilitasKesehatan.edit.form, ...stateFasilitasKesehatan.edit.form,
name: formData.name, ...formData,
informasiUmum: {
fasilitas: formData.informasiUmum.fasilitas,
alamat: formData.informasiUmum.alamat,
jamOperasional: formData.informasiUmum.jamOperasional,
},
layananUnggulan: {
content: formData.layananUnggulan.content,
},
dokterdanTenagaMedis: {
name: formData.dokterdanTenagaMedis.name,
specialist: formData.dokterdanTenagaMedis.specialist,
jadwal: formData.dokterdanTenagaMedis.jadwal,
},
fasilitasPendukung: {
content: formData.fasilitasPendukung.content,
},
prosedurPendaftaran: {
content: formData.prosedurPendaftaran.content,
},
tarifDanLayanan: {
layanan: formData.tarifDanLayanan.layanan,
tarif: formData.tarifDanLayanan.tarif,
},
}; };
const success = await stateFasilitasKesehatan.edit.submit(); const success = await stateFasilitasKesehatan.edit.submit();
if (success) { if (success) {
@@ -150,208 +125,197 @@ function EditFasilitasKesehatan() {
} }
} catch (error) { } catch (error) {
console.error("Error updating fasilitas kesehatan:", error); console.error("Error updating fasilitas kesehatan:", error);
toast.error(error instanceof Error ? error.message : "Gagal memperbarui data fasilitas kesehatan"); toast.error("Terjadi kesalahan saat memperbarui data fasilitas kesehatan");
} }
}; };
return ( return (
<Box> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Box mb={10}> {/* Header */}
<Button onClick={() => router.back()} variant="subtle" color="blue"> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
</Button> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Box> <IconArrowBack color={colors['blue-button']} size={24} />
<Stack gap="xs"> </Button>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> </Tooltip>
<Stack gap="xs"> <Title order={4} ml="sm" c="dark">
<Title order={3}>Edit Fasilitas Kesehatan</Title> Edit Fasilitas Kesehatan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Fasilitas Kesehatan"
placeholder="Masukkan nama fasilitas kesehatan"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
required
/>
{/* Informasi Umum */}
<Box>
<Text fw="bold" mb={5}>Informasi Umum</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Nama Fasilitas Kesehatan</Text>} label="Fasilitas"
placeholder="masukkan nama fasilitas kesehatan" value={formData.informasiUmum.fasilitas}
value={formData.name} onChange={(e) =>
onChange={(e) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
name: e.target.value informasiUmum: { ...prev.informasiUmum, fasilitas: e.target.value },
})); }))
}} }
/> />
<Box> <TextInput
<Text fz="md" fw="bold">Informasi Umum</Text> label="Alamat"
<TextInput value={formData.informasiUmum.alamat}
label={<Text fz="sm" fw="bold">Fasilitas</Text>} onChange={(e) =>
placeholder="masukkan fasilitas" setFormData(prev => ({
value={formData.informasiUmum.fasilitas} ...prev,
onChange={(e) => { informasiUmum: { ...prev.informasiUmum, alamat: e.target.value },
setFormData(prev => ({ }))
...prev, }
informasiUmum: { />
...prev.informasiUmum, <TextInput
fasilitas: e.target.value label="Jam Operasional"
} value={formData.informasiUmum.jamOperasional}
})); onChange={(e) =>
}} setFormData(prev => ({
/> ...prev,
<TextInput informasiUmum: { ...prev.informasiUmum, jamOperasional: e.target.value },
label={<Text fz="sm" fw="bold">Alamat</Text>} }))
placeholder="masukkan alamat" }
value={formData.informasiUmum.alamat} />
onChange={(e) => { </Box>
setFormData(prev => ({
...prev,
informasiUmum: {
...prev.informasiUmum,
alamat: e.target.value
}
}));
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Jam Operasional</Text>}
placeholder="masukkan jam operasional"
value={formData.informasiUmum.jamOperasional}
onChange={(e) => {
setFormData(prev => ({
...prev,
informasiUmum: {
...prev.informasiUmum,
jamOperasional: e.target.value
}
}));
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Layanan Unggulan</Text>
<EditEditor
value={formData.layananUnggulan.content}
onChange={(e) => {
setFormData(prev => ({
...prev,
layananUnggulan: {
...prev.layananUnggulan,
content: e
}
}));
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Dokter dan Tenaga Medis</Text>
<TextInput
label={<Text fz="sm" fw="bold">Nama Dokter</Text>}
placeholder="masukkan nama dokter"
value={formData.dokterdanTenagaMedis.name}
onChange={(e) => {
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: {
...prev.dokterdanTenagaMedis,
name: e.target.value
}
}));
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Specialist</Text>}
placeholder="masukkan specialist"
value={formData.dokterdanTenagaMedis.specialist}
onChange={(e) => {
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: {
...prev.dokterdanTenagaMedis,
specialist: e.target.value
}
}));
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Jadwal</Text>}
placeholder="masukkan jadwal"
value={formData.dokterdanTenagaMedis.jadwal}
onChange={(e) => {
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: {
...prev.dokterdanTenagaMedis,
jadwal: e.target.value
}
}));
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Fasilitas Pendukung</Text>
<EditEditor
value={formData.fasilitasPendukung.content}
onChange={(e) => {
setFormData(prev => ({
...prev,
fasilitasPendukung: {
...prev.fasilitasPendukung,
content: e
}
}));
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Prosedur Pendaftaran</Text>
<EditEditor
value={formData.prosedurPendaftaran.content}
onChange={(e) => {
setFormData(prev => ({
...prev,
prosedurPendaftaran: {
...prev.prosedurPendaftaran,
content: e
}
}));
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Tarif dan Layanan</Text>
<TextInput
label={<Text fz="sm" fw="bold">Tarif</Text>}
placeholder="masukkan tarif"
value={formData.tarifDanLayanan.tarif}
onChange={(e) => {
setFormData(prev => ({
...prev,
tarifDanLayanan: {
...prev.tarifDanLayanan,
tarif: e.target.value
}
}));
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Layanan</Text>}
placeholder="masukkan layanan"
value={formData.tarifDanLayanan.layanan}
onChange={(e) => {
setFormData(prev => ({
...prev,
tarifDanLayanan: {
...prev.tarifDanLayanan,
layanan: e.target.value
}
}));
}}
/>
</Box>
<Button {/* Layanan Unggulan */}
onClick={handleSubmit} <Box>
bg={colors['blue-button']} <Text fw="bold" mb={5}>Layanan Unggulan</Text>
loading={stateFasilitasKesehatan.edit.loading} <EditEditor
> value={formData.layananUnggulan.content}
Simpan onChange={(e) =>
</Button> setFormData(prev => ({
</Stack> ...prev,
</Paper> layananUnggulan: { content: e },
</Stack> }))
}
/>
</Box>
{/* Dokter dan Tenaga Medis */}
<Box>
<Text fw="bold" mb={5}>Dokter dan Tenaga Medis</Text>
<TextInput
label="Nama Dokter"
value={formData.dokterdanTenagaMedis.name}
onChange={(e) =>
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: { ...prev.dokterdanTenagaMedis, name: e.target.value },
}))
}
/>
<TextInput
label="Specialist"
value={formData.dokterdanTenagaMedis.specialist}
onChange={(e) =>
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: { ...prev.dokterdanTenagaMedis, specialist: e.target.value },
}))
}
/>
<TextInput
label="Jadwal"
value={formData.dokterdanTenagaMedis.jadwal}
onChange={(e) =>
setFormData(prev => ({
...prev,
dokterdanTenagaMedis: { ...prev.dokterdanTenagaMedis, jadwal: e.target.value },
}))
}
/>
</Box>
{/* Fasilitas Pendukung */}
<Box>
<Text fw="bold" mb={5}>Fasilitas Pendukung</Text>
<EditEditor
value={formData.fasilitasPendukung.content}
onChange={(e) =>
setFormData(prev => ({
...prev,
fasilitasPendukung: { content: e },
}))
}
/>
</Box>
{/* Prosedur Pendaftaran */}
<Box>
<Text fw="bold" mb={5}>Prosedur Pendaftaran</Text>
<EditEditor
value={formData.prosedurPendaftaran.content}
onChange={(e) =>
setFormData(prev => ({
...prev,
prosedurPendaftaran: { content: e },
}))
}
/>
</Box>
{/* Tarif dan Layanan */}
<Box>
<Text fw="bold" mb={5}>Tarif dan Layanan</Text>
<TextInput
label="Tarif"
value={formData.tarifDanLayanan.tarif}
onChange={(e) =>
setFormData(prev => ({
...prev,
tarifDanLayanan: { ...prev.tarifDanLayanan, tarif: e.target.value },
}))
}
/>
<TextInput
label="Layanan"
value={formData.tarifDanLayanan.layanan}
onChange={(e) =>
setFormData(prev => ({
...prev,
tarifDanLayanan: { ...prev.tarifDanLayanan, layanan: e.target.value },
}))
}
/>
</Box>
{/* Tombol Simpan */}
<Group justify="flex-end">
<Button
onClick={handleSubmit}
radius="md"
size="md"
loading={stateFasilitasKesehatan.edit.loading}
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> </Box>
); );
} }

View File

@@ -2,133 +2,169 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Flex, Grid, GridCol, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function DetailFasilitasKesehatan() { function DetailFasilitasKesehatan() {
const params = useParams() const params = useParams();
const router = useRouter(); const router = useRouter();
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan) const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const [modalHapus, setModalHapus] = useState(false); const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => { useShallowEffect(() => {
stateFasilitasKesehatan.findUnique.load(params?.id as string) state.findUnique.load(params?.id as string);
}, []) }, []);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
stateFasilitasKesehatan.delete.byId(selectedId) state.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan") router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan'
);
} }
} };
if (!stateFasilitasKesehatan.findUnique.data) { if (!state.findUnique.data) {
return ( return (
<Stack py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
) );
} }
const data = state.findUnique.data;
return ( return (
<Box> <Box py={10}>
<Box mb={10}> {/* Tombol Back */}
<Button variant="subtle" onClick={() => router.back()}> <Button
<IconArrowBack color={colors['blue-button']} size={25} /> variant="subtle"
</Button> onClick={() => router.back()}
</Box> leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
<Paper bg={colors['white-1']} p={'md'}> mb={15}
<Stack> >
<Grid> Kembali
<GridCol span={12}> </Button>
<Text fz={"xl"} fw={"bold"}>Detail Fasilitas Kesehatan</Text>
</GridCol>
{/* <GridCol span={12}>
<Flex gap={"xs"}>
<Button color={colors['blue-button']} onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${params?.id}/dokter-tenaga-medis`)}>
Tambah Dokter
</Button>
<Button color={colors['blue-button']} onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${params?.id}/layanan-unggulan/create`)}>
Tambah Layanan
</Button>
</Flex>
</GridCol> */}
</Grid>
{stateFasilitasKesehatan.findUnique.data ? (
<Paper bg={colors['BG-trans']} p={'md'}>
<Stack gap={"xs"}>
<Box>
<Text fz={"lg"} fw={"bold"}>Nama Fasilitas Kesehatan</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.name}</Text>
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Informasi Umum</Text>
<Text fz={"md"} fw={"bold"}>Fasilitas</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.informasiumum.fasilitas}</Text>
<Text fz={"md"} fw={"bold"}>Alamat</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.informasiumum.alamat}</Text>
<Text fz={"md"} fw={"bold"}>Jam Operasional</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.informasiumum.jamOperasional}</Text>
</Box>
<Box>
<Text fz={"md"} fw={"bold"}>Layanan Unggulan</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateFasilitasKesehatan.findUnique.data.layananunggulan.content }} />
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Fasilitas Pendukung</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateFasilitasKesehatan.findUnique.data.fasilitaspendukung.content }} />
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Prosedur Pendaftaran</Text>
<Text fz={"md"} dangerouslySetInnerHTML={{ __html: stateFasilitasKesehatan.findUnique.data.prosedurpendaftaran.content }} />
</Box>
<Box>
<Text fz={"md"} fw={"bold"}>Dokter dan Tenaga Medis</Text>
<Text fz={"md"} fw={"bold"}>Nama Dokter</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.dokterdantenagamedis.name}</Text>
<Text fz={"md"} fw={"bold"}>Specialist</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.dokterdantenagamedis.specialist}</Text>
<Text fz={"md"} fw={"bold"}>Jadwal</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.dokterdantenagamedis.jadwal}</Text>
</Box>
<Box>
<Text fz={"lg"} fw={"bold"}>Tarif dan Layanan</Text>
<Text fz={"md"} fw={"bold"}>Layanan</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.tarifdanlayanan.layanan}</Text>
<Text fz={"md"} fw={"bold"}>Tarif</Text>
<Text fz={"md"}>{stateFasilitasKesehatan.findUnique.data.tarifdanlayanan.tarif}</Text>
</Box>
<Box>
<Flex gap={"xs"}>
<Button color="red" onClick={() => {
if (stateFasilitasKesehatan.findUnique.data) {
setSelectedId(stateFasilitasKesehatan.findUnique.data.id)
setModalHapus(true)
}
}}>
<IconX size={20} />
</Button>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${stateFasilitasKesehatan.findUnique.data?.id}/edit`)} color="green">
<IconEdit size={20} />
</Button>
</Flex>
</Box>
</Stack>
</Paper>
) : null}
</Stack>
{/* Wrapper Detail */}
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Fasilitas Kesehatan
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Nama Fasilitas</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Informasi Umum</Text>
<Text fz="md" fw="bold">Fasilitas</Text>
<Text fz="md" c="dimmed">{data.informasiumum?.fasilitas || '-'}</Text>
<Text fz="md" fw="bold">Alamat</Text>
<Text fz="md" c="dimmed">{data.informasiumum?.alamat || '-'}</Text>
<Text fz="md" fw="bold">Jam Operasional</Text>
<Text fz="md" c="dimmed">{data.informasiumum?.jamOperasional || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Layanan Unggulan</Text>
<Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.layananunggulan?.content || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Fasilitas Pendukung</Text>
<Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.fasilitaspendukung?.content || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Prosedur Pendaftaran</Text>
<Text fz="md" c="dimmed" dangerouslySetInnerHTML={{ __html: data.prosedurpendaftaran?.content || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text>
<Text fz="md" fw="bold">Nama</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text>
<Text fz="md" fw="bold">Spesialis</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text>
<Text fz="md" fw="bold">Jadwal</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Tarif & Layanan</Text>
<Text fz="md" fw="bold">Layanan</Text>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text>
<Text fz="md" fw="bold">Tarif</Text>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text>
</Box>
{/* Aksi */}
<Group gap="sm">
<Tooltip label="Hapus Data" withArrow position="top">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
</Tooltip>
<Tooltip label="Edit Data" withArrow position="top">
<Button
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Tooltip>
</Group>
</Stack>
</Paper>
</Stack>
</Paper> </Paper>
{/* Modal Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
onClose={() => setModalHapus(false)} onClose={() => setModalHapus(false)}

View File

@@ -2,7 +2,17 @@
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -10,174 +20,196 @@ import { useProxy } from 'valtio/utils';
function CreateFasilitasKesehatan() { function CreateFasilitasKesehatan() {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan) const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const router = useRouter(); const router = useRouter();
const resetForm = () => { const resetForm = () => {
stateFasilitasKesehatan.create.form = { stateFasilitasKesehatan.create.form = {
name: "", name: '',
informasiUmum: { informasiUmum: {
fasilitas: "", fasilitas: '',
alamat: "", alamat: '',
jamOperasional: "", jamOperasional: '',
}, },
layananUnggulan: { layananUnggulan: {
content: "", content: '',
}, },
dokterdanTenagaMedis: { dokterdanTenagaMedis: {
name: "", name: '',
specialist: "", specialist: '',
jadwal: "", jadwal: '',
}, },
fasilitasPendukung: { fasilitasPendukung: {
content: "", content: '',
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: "", content: '',
}, },
tarifDanLayanan: { tarifDanLayanan: {
layanan: "", layanan: '',
tarif: "", tarif: '',
}, },
}; };
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
await stateFasilitasKesehatan.create.submit(); await stateFasilitasKesehatan.create.submit();
toast.success('Data berhasil disimpan');
toast.success("Data berhasil disimpan");
resetForm(); resetForm();
// After successful submission, redirect to the list page
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan'); router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan');
} };
return ( return (
<Box component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
</Button> <Button
</Box> variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Tambah Data Fasilitas Kesehatan
</Title>
</Group>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> {/* Form */}
<Stack gap="xs"> <Paper
<Title order={3}>Create Fasilitas Kesehatan</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Nama Fasilitas Kesehatan</Text>} label={"Nama Fasilitas Kesehatan"}
placeholder="masukkan nama fasilitas kesehatan" placeholder="Masukkan nama fasilitas kesehatan"
value={stateFasilitasKesehatan.create.form.name} value={stateFasilitasKesehatan.create.form.name}
onChange={(e) => { onChange={(e) => (stateFasilitasKesehatan.create.form.name = e.target.value)}
stateFasilitasKesehatan.create.form.name = e.target.value; required
}}
/> />
{/* Informasi Umum */}
<Box> <Box>
<Text fz="md" fw="bold">Informasi Umum</Text> <Text fz="md" fw="bold" mb={5}>Informasi Umum</Text>
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Fasilitas</Text>} label="Fasilitas"
placeholder="masukkan fasilitas" placeholder="Masukkan fasilitas"
value={stateFasilitasKesehatan.create.form.informasiUmum.fasilitas} value={stateFasilitasKesehatan.create.form.informasiUmum.fasilitas}
onChange={(e) => { onChange={(e) => (stateFasilitasKesehatan.create.form.informasiUmum.fasilitas = e.target.value)}
stateFasilitasKesehatan.create.form.informasiUmum.fasilitas = e.target.value; required
}}
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Alamat</Text>} label="Alamat"
placeholder="masukkan alamat" placeholder="Masukkan alamat"
value={stateFasilitasKesehatan.create.form.informasiUmum.alamat} value={stateFasilitasKesehatan.create.form.informasiUmum.alamat}
onChange={(e) => { onChange={(e) => (stateFasilitasKesehatan.create.form.informasiUmum.alamat = e.target.value)}
stateFasilitasKesehatan.create.form.informasiUmum.alamat = e.target.value; required
}}
/> />
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Jam Operasional</Text>} label="Jam Operasional"
placeholder="masukkan jam operasional" placeholder="Masukkan jam operasional"
value={stateFasilitasKesehatan.create.form.informasiUmum.jamOperasional} value={stateFasilitasKesehatan.create.form.informasiUmum.jamOperasional}
onChange={(e) => { onChange={(e) => (stateFasilitasKesehatan.create.form.informasiUmum.jamOperasional = e.target.value)}
stateFasilitasKesehatan.create.form.informasiUmum.jamOperasional = e.target.value; required
}}
/> />
</Box> </Box>
{/* Layanan Unggulan */}
<Box> <Box>
<Text fz="md" fw="bold">Layanan Unggulan</Text> <Text fz="md" fw="bold" mb={5}>Layanan Unggulan</Text>
<CreateEditor <CreateEditor
value={stateFasilitasKesehatan.create.form.layananUnggulan.content} value={stateFasilitasKesehatan.create.form.layananUnggulan.content}
onChange={(e) => { onChange={(val) => (stateFasilitasKesehatan.create.form.layananUnggulan.content = val)}
stateFasilitasKesehatan.create.form.layananUnggulan.content = e;
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Dokter dan Tenaga Medis</Text>
<TextInput
label={<Text fz="sm" fw="bold">Nama Dokter</Text>}
placeholder="masukkan nama dokter"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name}
onChange={(e) => {
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value;
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Specialist</Text>}
placeholder="masukkan specialist"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist}
onChange={(e) => {
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value;
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Jadwal</Text>}
placeholder="masukkan jadwal"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
onChange={(e) => {
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value;
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Fasilitas Pendukung</Text>
<CreateEditor
value={stateFasilitasKesehatan.create.form.fasilitasPendukung.content}
onChange={(e) => {
stateFasilitasKesehatan.create.form.fasilitasPendukung.content = e;
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Prosedur Pendaftaran</Text>
<CreateEditor
value={stateFasilitasKesehatan.create.form.prosedurPendaftaran.content}
onChange={(e) => {
stateFasilitasKesehatan.create.form.prosedurPendaftaran.content = e;
}}
/>
</Box>
<Box>
<Text fz="md" fw="bold">Tarif dan Layanan</Text>
<TextInput
label={<Text fz="sm" fw="bold">Tarif</Text>}
placeholder="masukkan tarif"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
onChange={(e) => {
stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value;
}}
/>
<TextInput
label={<Text fz="sm" fw="bold">Layanan</Text>}
placeholder="masukkan layanan"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
onChange={(e) => {
stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value;
}}
/> />
</Box> </Box>
<Button onClick={handleSubmit} bg={colors['blue-button']}> {/* Dokter dan Tenaga Medis */}
Simpan <Box>
</Button> <Text fz="md" fw="bold" mb={5}>Dokter dan Tenaga Medis</Text>
<TextInput
label="Nama Dokter"
placeholder="Masukkan nama dokter"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value)}
required
/>
<TextInput
label="Spesialis"
placeholder="Masukkan spesialis"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)}
required
/>
<TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value)}
required
/>
</Box>
{/* Fasilitas Pendukung */}
<Box>
<Text fz="md" fw="bold" mb={5}>Fasilitas Pendukung</Text>
<CreateEditor
value={stateFasilitasKesehatan.create.form.fasilitasPendukung.content}
onChange={(val) => (stateFasilitasKesehatan.create.form.fasilitasPendukung.content = val)}
/>
</Box>
{/* Prosedur Pendaftaran */}
<Box>
<Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text>
<CreateEditor
value={stateFasilitasKesehatan.create.form.prosedurPendaftaran.content}
onChange={(val) => (stateFasilitasKesehatan.create.form.prosedurPendaftaran.content = val)}
/>
</Box>
{/* Tarif dan Layanan */}
<Box>
<Text fz="md" fw="bold" mb={5}>Tarif dan Layanan</Text>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value)}
required
/>
<TextInput
label="Layanan"
placeholder="Masukkan layanan"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value)}
required
/>
</Box>
{/* Submit */}
<Group justify="right" mt="md">
<Button
type="submit"
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
</Button>
</Group>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,114 +1,172 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Paper,
Pagination,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import JudulList from '../../../_com/judulList';
import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
function FasilitasKesehatan() { function FasilitasKesehatan() {
const router = useRouter();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
// const router = useRouter();
return ( return (
<Box> <Box>
{/* <Grid> {/* Tombol Back */}
<GridCol span={12}> <Box mb={10}>
<HeaderSearch <Button variant="subtle" onClick={() => router.back()}>
title='Fasilitas Kesehatan' <IconArrowBack color={colors["blue-button"]} size={25} />
placeholder='pencarian' </Button>
searchIcon={<IconSearch size={20} />} </Box>
value={search}
onChange={(e) => setSearch(e.currentTarget.value)} {/* Header Search */}
/>
</GridCol>
<GridCol span={12}>
<Flex gap={"xs"}>
<Button color={colors['blue-button']} onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis`)}>
<IconList size={20} /> List Dokter
</Button>
<Button color={colors['blue-button']} onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan`)}>
<IconList size={20} /> List Layanan
</Button>
</Flex>
</GridCol>
</Grid> */}
<HeaderSearch <HeaderSearch
title='Fasilitas Kesehatan' title='Fasilitas Kesehatan'
placeholder='pencarian' placeholder='Cari nama, alamat, atau jam operasional...'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
/> />
<ListFasilitasKesehatan search={search} /> <ListFasilitasKesehatan search={search} />
</Box> </Box>
); );
} }
function ListFasilitasKesehatan({ search }: { search: string }) { function ListFasilitasKesehatan({ search }: { search: string }) {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan) const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan)
const router = useRouter(); const router = useRouter();
const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany;
useShallowEffect(() => { useShallowEffect(() => {
stateFasilitasKesehatan.findMany.load() load(page, 10, search);
}, []) }, [page, search]);
const filteredData = (stateFasilitasKesehatan.findMany.data || []).filter(item => { const filteredData = data || [];
const keyword = search.toLowerCase();
return (
item.name.toLowerCase().includes(keyword) ||
item.informasiumum.alamat.toLowerCase().includes(keyword) ||
item.informasiumum.jamOperasional.toLowerCase().includes(keyword)
);
});
if (!stateFasilitasKesehatan.findMany.data) { if (loading || !data) {
return ( return (
<Box py={10}> <Stack py={10}>
<Skeleton h={500} /> <Skeleton height={600} radius="md" />
</Box> </Stack>
) )
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Fasilitas Kesehatan' <Title order={4}>Daftar Fasilitas Kesehatan</Title>
href='/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/create' <Tooltip label="Tambah Fasilitas Kesehatan" withArrow>
/> <Button
<Box style={{ overflowX: "auto" }}> leftSection={<IconPlus size={18} />}
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> color="blue"
<TableThead> variant="light"
<TableTr> onClick={() =>
<TableTh>Fasilitas Kesehatan</TableTh> router.push(
<TableTh>Dokter</TableTh> '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/create'
<TableTh>Layanan</TableTh> )
<TableTh>Detail</TableTh> }
</TableTr> >
</TableThead> Tambah Baru
<TableTbody> </Button>
{filteredData.map((item) => ( </Tooltip>
</Group>
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Fasilitas Kesehatan</TableTh>
<TableTh>Dokter</TableTh>
<TableTh>Layanan</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.dokterdantenagamedis.name}</TableTd>
<TableTd>{item.tarifdanlayanan.layanan}</TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Text fw={500} truncate="end" lineClamp={1}>
<IconDeviceImacCog size={25} /> {item.name}
</Text>
</TableTd>
<TableTd>{item.dokterdantenagamedis?.name || '-'}</TableTd>
<TableTd>{item.tarifdanlayanan?.layanan || '-'}</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={4}>
</Stack> <Center py={20}>
<Text color="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box> </Box>
) )
} }

Some files were not shown because too many files have changed in this diff Show More