Compare commits

..

1 Commits

834 changed files with 21929 additions and 48169 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,9 +3,9 @@
"version": "0.1.5", "version": "0.1.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "bun --bun next dev",
"build": "next build", "build": "bun --bun next build",
"start": "next start" "start": "bun --bun next start"
}, },
"prisma": { "prisma": {
"seed": "bun run prisma/seed.ts" "seed": "bun run prisma/seed.ts"
@@ -19,7 +19,6 @@
"@elysiajs/static": "^1.3.0", "@elysiajs/static": "^1.3.0",
"@elysiajs/stream": "^1.1.0", "@elysiajs/stream": "^1.1.0",
"@elysiajs/swagger": "^1.2.0", "@elysiajs/swagger": "^1.2.0",
"@emotion/react": "^11.14.0",
"@mantine/carousel": "^7.16.2", "@mantine/carousel": "^7.16.2",
"@mantine/charts": "^7.17.1", "@mantine/charts": "^7.17.1",
"@mantine/core": "^7.17.4", "@mantine/core": "^7.17.4",
@@ -27,7 +26,6 @@
"@mantine/dropzone": "^8.1.1", "@mantine/dropzone": "^8.1.1",
"@mantine/form": "^8.1.0", "@mantine/form": "^8.1.0",
"@mantine/hooks": "^7.17.4", "@mantine/hooks": "^7.17.4",
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.3.1",
@@ -54,13 +52,11 @@
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"colors": "^1.4.0", "colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"elysia": "^1.3.5", "elysia": "^1.3.5",
"embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^7.1.0",
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.23.5", "framer-motion": "^12.23.5",
@@ -84,7 +80,6 @@
"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-exif-orientation-img": "^0.1.5",
"react-international-phone": "^4.6.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",

View File

@@ -1,15 +1,14 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-preset-mantine': {}, 'postcss-preset-mantine': {},
'postcss-simple-vars': { 'postcss-simple-vars': {
variables: { variables: {
/* Mobile first */ 'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-xs': '30em', // 480px → mobile kecilnormal 'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-sm': '48em', // 768px → tablet / mobile landscape 'mantine-breakpoint-md': '62em',
'mantine-breakpoint-md': '64em', // 1024px → laptop & desktop kecil 'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-lg': '80em', // 1280px → desktop standar 'mantine-breakpoint-xl': '88em',
'mantine-breakpoint-xl': '90em', // 1440px+ → desktop besar },
}, },
}, },
}, };
};

View File

@@ -1,32 +1,23 @@
[ [
{ {
"id": "0", "id": "role-1",
"name": "DEVELOPER", "name": "ADMIN DESA",
"description": "Developer", "description": "Administrator Desa",
"isActive": true "permissions": ["manage_users", "manage_content", "view_reports"],
}, "isActive": true
{ },
"id": "1", {
"name": "SUPER ADMIN", "id": "role-2",
"description": "Administrator", "name": "ADMIN KESEHATAN",
"isActive": true "description": "Administrator Bidang Kesehatan",
}, "permissions": ["manage_health_data", "view_reports"],
{ "isActive": true
"id": "2", },
"name": "ADMIN DESA", {
"description": "Administrator Desa", "id": "role-3",
"isActive": true "name": "ADMIN SEKOLAH",
}, "description": "Administrator Sekolah",
{ "permissions": ["manage_school_data", "view_reports"],
"id": "3", "isActive": true
"name": "ADMIN KESEHATAN", }
"description": "Administrator Bidang Kesehatan", ]
"isActive": true
},
{
"id": "4",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true
}
]

View File

@@ -1,10 +1,23 @@
[ [
{ {
"id": "cmie1o0zh0002vn132vtzg7hh", "id": "user-1",
"username": "SuperAdmin-Nico", "nama": "Admin Desa",
"nomor": "6289647037426", "nomor": "089647037426",
"roleId": 0, "roleId": "role-1",
"isActive": true, "isActive": true
"sessionInvalid": false },
{
"id": "user-2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "role-2",
"isActive": true
},
{
"id": "user-3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "role-3",
"isActive": true
} }
] ]

View File

@@ -136,7 +136,6 @@ model MediaSosial {
name String name String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
icon String?
iconUrl String? @db.VarChar(255) iconUrl String? @db.VarChar(255)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -185,46 +184,18 @@ model SdgsDesa {
//========================================= APBDes ========================================= // //========================================= APBDes ========================================= //
model APBDes { model APBDes {
id String @id @default(cuid()) id String @id @default(cuid())
tahun Int? name String
name String? // misalnya: "APBDes Tahun 2025" jumlah String
deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String? imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String? fileId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? // opsional, tidak perlu default now() deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
} }
model APBDesItem {
id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([level])
@@index([apbdesId])
}
//========================================= PRESTASI DESA ========================================= // //========================================= PRESTASI DESA ========================================= //
model PrestasiDesa { model PrestasiDesa {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -783,22 +754,24 @@ model Penghargaan {
// ========================================= FASILITAS KESEHATAN ========================================= // // ========================================= FASILITAS KESEHATAN ========================================= //
model FasilitasKesehatan { model FasilitasKesehatan {
id String @id @default(cuid()) id String @id @default(cuid())
name String name 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)
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id]) informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
informasiUmumId String informasiUmumId String
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id]) layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
layananUnggulanId String layananUnggulanId String
dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter") dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id])
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id]) dokterdanTenagaMedisId String
fasilitasPendukungId String fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id]) fasilitasPendukungId String
prosedurPendaftaranId String prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
tarifdanlayanan TarifDanLayanan[] @relation("Tarif") prosedurPendaftaranId String
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
tarifDanLayananId String
} }
model InformasiUmum { model InformasiUmum {
@@ -824,20 +797,15 @@ model LayananUnggulan {
} }
model DokterdanTenagaMedis { model DokterdanTenagaMedis {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
specialist String specialist String
jadwal String jadwal String
jadwalLibur String? createdAt DateTime @default(now())
jamBukaOperasional String? updatedAt DateTime @updatedAt
jamTutupOperasional String? deletedAt DateTime @default(now())
jamBukaLibur String? isActive Boolean @default(true)
jamTutupLibur String? FasilitasKesehatan FasilitasKesehatan[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
} }
model FasilitasPendukung { model FasilitasPendukung {
@@ -868,7 +836,7 @@ model TarifDanLayanan {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif") FasilitasKesehatan FasilitasKesehatan[]
} }
// ========================================= JADWAL KEGIATAN ========================================= // // ========================================= JADWAL KEGIATAN ========================================= //
@@ -1974,28 +1942,23 @@ model KeunggulanProgram {
} }
model BeasiswaPendaftar { model BeasiswaPendaftar {
id String @id @default(cuid()) id String @id @default(cuid())
namaLengkap String namaLengkap String
nis String? nik String @unique
kelas String?
jenisKelamin JenisKelamin
alamatDomisili String?
tempatLahir String tempatLahir String
tanggalLahir DateTime tanggalLahir DateTime
namaOrtu String? jenisKelamin JenisKelamin
nik String @unique kewarganegaraan String
pekerjaanOrtu String? agama Agama
penghasilan String? alamatKTP String
alamatDomisili String?
noHp String noHp String
kewarganegaraan String? email String @unique
agama Agama? statusPernikahan StatusPernikahan
alamatKTP String?
email String? @unique
statusPernikahan StatusPernikahan?
ukuranBaju UkuranBaju? ukuranBaju UkuranBaju?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum JenisKelamin { enum JenisKelamin {
@@ -2167,28 +2130,25 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= // // ========================================= USER ========================================= //
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String username String
nomor String @unique nomor String @unique
roleId String @default("2") role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(false) roleId String @default("1")
sessionInvalid Boolean @default(false) instansi String?
lastLogin DateTime? UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @default(now()) @updatedAt lastLogin DateTime?
permissions Json? createdAt DateTime @default(now())
sessions UserSession[] // ✅ Relasi one-to-many updatedAt DateTime @updatedAt
role Role @relation(fields: [roleId], references: [id]) deletedAt DateTime?
menuAccesses UserMenuAccess[]
@@map("users")
} }
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String? description String?
permissions Json? permissions Json // Menyimpan permission dalam format JSON
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2207,32 +2167,26 @@ model KodeOtp {
otp Int otp Int
} }
model UserSession { // Tabel untuk menyimpan permission
id String @id @default(cuid()) model Permission {
token String @db.Text // ✅ JWT bisa panjang id String @id @default(cuid())
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten) name String @unique
active Boolean @default(true) description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("permissions")
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
@@index([userId]) // ✅ Index untuk query cepat
@@index([token]) // ✅ Index untuk verify cepat
@@map("user_sessions")
} }
model UserMenuAccess { model UserSession {
id String @id @default(cuid()) id String @id @default(cuid())
userId String token String
menuId String // ID menu (misal: "Landing Page", "Kesehatan") expires DateTime?
createdAt DateTime @default(now()) active Boolean @default(true)
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
user User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
userId String @unique
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
} }
// ========================================= DATA PENDIDIKAN ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json"; import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -55,110 +54,73 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
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 roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json"; import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json"; import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets"; import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique"; import { safeSeedUnique } from "./safeseedUnique";
(async () => { (async () => {
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
for (const r of roles) { for (const r of roles) {
try { await safeSeedUnique("role", { id: r.id }, {
// ✅ Destructure to remove permissions if exists name: r.name,
const { permissions, ...roleData } = r as any; description: r.description,
permissions: r.permissions,
await safeSeedUnique( isActive: r.isActive,
"role", });
{ name: roleData.name },
{
id: roleData.id,
name: roleData.name,
description: roleData.description,
permissions: roleData.permissions || {}, // ✅ Include permissions
isActive: roleData.isActive,
}
);
console.log(`✅ Seeded role -> ${roleData.name}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
} else {
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
}
}
} }
console.log("✅ Roles seeding completed");
// =========== USER =========== console.log("✅ Roles seeded");
// =========== USERS ===========
console.log("🔄 Seeding users..."); console.log("🔄 Seeding users...");
for (const u of users) { for (const u of users) {
try { // First verify the role exists
// Verify role exists first const roleExists = await prisma.role.findUnique({
const roleExists = await prisma.role.findUnique({ where: { id: u.roleId },
where: { id: u.roleId.toString() }, });
select: { id: true }, // Only select id to minimize query
});
if (!roleExists) { if (!roleExists) {
console.error( console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
`❌ Role with id ${u.roleId} not found for user ${u.username}` continue;
);
continue;
}
await safeSeedUnique(
"user",
{ id: u.id },
{
username: u.username,
nomor: u.nomor,
roleId: u.roleId.toString(),
isActive: u.isActive,
sessionInvalid: false,
}
);
console.log(`✅ Seeded user -> ${u.username}`);
} catch (error: any) {
if (error.code === "P2003") {
console.error(
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
);
} else {
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
}
} }
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
});
} }
console.log("✅ Users seeding completed"); console.log("✅ Users seeded");
// =========== FILE STORAGE =========== // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage..."); console.log("🔄 Seeding file storage...");
for (const f of fileStorage) { for (const f of fileStorage) {
try { await prisma.fileStorage.upsert({
await prisma.fileStorage.upsert({ where: { id: f.id },
where: { id: f.id }, update: {
update: { name: f.name,
name: f.name, realName: f.realName,
realName: f.realName, path: f.path,
path: f.path, mimeType: f.mimeType,
mimeType: f.mimeType, link: f.link,
link: f.link, category: f.category,
category: f.category, },
}, create: {
create: { id: f.id,
id: f.id, name: f.name,
name: f.name, realName: f.realName,
realName: f.realName, path: f.path,
path: f.path, mimeType: f.mimeType,
mimeType: f.mimeType, link: f.link,
link: f.link, category: f.category,
category: f.category, },
}, });
});
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
} }
console.log("✅ File storage seeded"); console.log("✅ File storage seeded");
// =========== LANDING PAGE =========== // =========== LANDING PAGE ===========
@@ -577,40 +539,15 @@ import { safeSeedUnique } from "./safeseedUnique";
console.log("posisi organisasi berhasil"); console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID =========== // =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat(); const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) { for (const p of flattenedPegawai) {
if (emails.has(p.email)) { await prisma.pegawaiPPID.upsert({
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`); where: { id: p.id },
} update: p,
emails.add(p.email); create: p,
});
} }
console.log("pegawai berhasil");
for (const p of flattenedPegawai) {
try {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
);
} else {
console.error(
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
error.message
);
}
}
}
console.log("✅ pegawai PPID seeding completed");
// =========== SUBMENU VISI MISI PPID =========== // =========== SUBMENU VISI MISI PPID ===========
@@ -874,9 +811,7 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat(); const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki // ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort( const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki);
(a, b) => a.hierarki - b.hierarki
);
for (const p of sortedPosisiBumdes) { for (const p of sortedPosisiBumdes) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`); console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -956,7 +891,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data // Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({ const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k, ...k,
id: `kategori-${index + 1}`, id: `kategori-${index + 1}`
})); }));
for (const k of kategoriKegiatan) { for (const k of kategoriKegiatan) {
@@ -1248,6 +1183,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets // seed assets
await seedAssets(); await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -7,7 +7,6 @@ import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import Superscript from '@tiptap/extension-superscript'; import Superscript from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { useEffect } from 'react';
type CreateEditorProps = { type CreateEditorProps = {
value: string; value: string;
@@ -33,13 +32,6 @@ export default function CreateEditor({ value, onChange }: CreateEditorProps) {
}, },
}); });
// 👇 Tambahkan efek untuk sinkronisasi value dari luar (resetForm)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
return ( return (
<RichTextEditor editor={editor}> <RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)"> <RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">

View File

@@ -47,7 +47,6 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
editor.off('update', updateHandler); editor.off('update', updateHandler);
}; };
}, [editor, onChange]); }, [editor, onChange]);
return ( return (
<RichTextEditor editor={editor}> <RichTextEditor editor={editor}>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client'
import { Box, Group, rem, Select, SelectProps } from '@mantine/core'; import { Box, rem, Select } from '@mantine/core';
import { import {
IconAmbulance, IconAmbulance,
IconCash, IconCash,
@@ -25,7 +25,7 @@ import {
IconTrophy, IconTrophy,
IconTruckFilled, IconTruckFilled,
IconBuilding, IconBuilding,
IconAlertTriangle, IconAlertTriangle
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const iconMap = { const iconMap = {
@@ -38,26 +38,26 @@ const iconMap = {
scale: { label: 'Scale', icon: IconScale }, scale: { label: 'Scale', icon: IconScale },
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled }, clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
trash: { label: 'Trash', icon: IconTrashFilled }, trash: { label: 'Trash', icon: IconTrashFilled },
lingkunganSehat: { label: 'Lingkungan Sehat', icon: IconHomeEco }, lingkunganSehat: {label: 'Lingkungan Sehat', icon: IconHomeEco},
sumberOksigen: { label: 'Sumber Oksigen', icon: IconChristmasTreeFilled }, sumberOksigen: {label: 'Sumber Oksigen', icon: IconChristmasTreeFilled},
ekonomiBerkelanjutan: { label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp }, ekonomiBerkelanjutan: {label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp},
mencegahBencana: { label: 'Mencegah Bencana', icon: IconShieldFilled }, mencegahBencana: {label: 'Mencegah Bencana', icon: IconShieldFilled},
rumah: { label: 'Rumah', icon: IconHome }, rumah: {label: 'Rumah', icon: IconHome},
pohon: { label: 'Pohon', icon: IconTree }, pohon: {label: 'Pohon', icon: IconTree},
air: { label: 'Air', icon: IconDroplet }, air: {label: 'Air', icon: IconDroplet},
bantuan: { label: 'Bantuan', icon: IconCash }, bantuan: {label: 'Bantuan', icon: IconCash},
pelatihan: { label: 'Pelatihan', icon: IconSchool }, pelatihan: {label: 'Pelatihan', icon: IconSchool},
subsidi: { label: 'Subsidi', icon: IconShoppingCart }, subsidi: {label: 'Subsidi', icon: IconShoppingCart},
layananKesehatan: { label: 'Layanan Kesehatan', icon: IconHospital }, layananKesehatan: {label: 'Layanan Kesehatan', icon: IconHospital},
polisi: { label: 'Polisi', icon: IconShieldFilled }, polisi: {label: 'Polisi', icon: IconShieldFilled},
ambulans: { label: 'Ambulans', icon: IconAmbulance }, ambulans: {label: 'Ambulans', icon: IconAmbulance},
pemadam: { label: 'Pemadam', icon: IconFiretruck }, pemadam: {label: 'Pemadam', icon: IconFiretruck},
rumahSakit: { label: 'Rumah Sakit', icon: IconHospital }, rumahSakit: {label: 'Rumah Sakit', icon: IconHospital},
bangunan: { label: 'Bangunan', icon: IconBuilding }, bangunan: {label: 'Bangunan', icon: IconBuilding},
darurat: { label: 'Darurat', icon: IconAlertTriangle }, darurat: {label: 'Darurat', icon: IconAlertTriangle},
}; };
export type IconKey = keyof typeof iconMap; type IconKey = keyof typeof iconMap;
const iconList = Object.entries(iconMap).map(([value, data]) => ({ const iconList = Object.entries(iconMap).map(([value, data]) => ({
value, value,
@@ -67,52 +67,44 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
export default function SelectIconProgramEdit({ export default function SelectIconProgramEdit({
onChange, onChange,
value, value,
...props
}: { }: {
onChange: (value: IconKey | '') => void; onChange: (value: IconKey) => void;
value: IconKey | ''; value: IconKey;
} & Omit<SelectProps, 'onChange' | 'value' | 'data'>) { }) {
const IconComponent = iconMap[value]?.icon || null;
return ( return (
<Box maw={300}> <Box maw={300}>
<Select <Select
placeholder="Pilih ikon" placeholder="Pilih ikon"
value={value || ''} value={value}
onChange={(val: string | null) => { onChange={(value) => {
if (val) { if (value) onChange(value as IconKey);
onChange(val as IconKey);
} else {
onChange('');
}
}} }}
data={iconList} data={iconList}
renderOption={({ option }) => {
const Icon = iconMap[option.value as IconKey]?.icon;
return (
<Group gap="sm">
{Icon && <Icon size={18} stroke={1.5} />}
{option.label}
</Group>
);
}}
leftSection={ leftSection={
value && iconMap[value as IconKey] ? ( IconComponent && (
<Box ml={-4}> <Box>
{(() => { <IconComponent size={24} stroke={1.5} />
const Icon = iconMap[value as IconKey].icon;
return <Icon size={20} stroke={1.5} />;
})()}
</Box> </Box>
) : null )
} }
searchable withCheckIcon={false}
searchable={false}
rightSectionWidth={0}
styles={{ styles={{
input: { input: {
paddingLeft: 40, textAlign: 'left',
fontSize: rem(16), fontSize: rem(16),
paddingLeft: 40,
},
section: {
left: 10,
right: 'auto',
}, },
}} }}
{...props}
/> />
</Box> </Box>
); );
} }

View File

@@ -1,76 +0,0 @@
'use client';
import { Box, Image, Select, rem } from '@mantine/core';
const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSosialMedia({
value,
onChange,
}: {
value: SosmedKey;
onChange: (value: SosmedKey) => void;
}) {
const selected = value;
const selectedImage = sosmedMap[selected]?.src;
return (
<Box maw={300}>
<Select
placeholder="Pilih sosial media"
value={selected}
data={sosmedList}
searchable={false}
withCheckIcon={false}
onChange={(val) => val && onChange(val as SosmedKey)}
styles={{
input: {
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 36,
},
section: {
left: 10,
right: 'auto',
},
}}
/>
{/* 🔥 PREVIEW DIPISAH DI LUAR SELECT */}
{selectedImage && (
<Box mt="md">
<Image
alt=""
src={selectedImage}
radius="md"
style={{
width: 120,
height: 120,
objectFit: 'contain',
border: '1px solid #eee',
padding: 8,
}}
/>
</Box>
)}
</Box>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import { Box, Select } from '@mantine/core';
import { useEffect, useState } from 'react';
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSocialMediaEdit({
value,
onChange,
}: {
value: string;
onChange: (val: SosmedKey) => void;
}) {
const [selected, setSelected] = useState<SosmedKey>('facebook');
useEffect(() => {
if (value && sosmedMap[value as SosmedKey]) {
setSelected(value as SosmedKey);
}
}, [value]);
return (
<Box>
<Select
label="Jenis Media Sosial"
value={selected}
data={sosmedList}
searchable={false}
onChange={(val) => {
if (!val) return;
setSelected(val as SosmedKey);
onChange(val as SosmedKey);
}}
/>
</Box>
);
}

View File

@@ -39,7 +39,7 @@ const penghargaanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
penghargaanState.findMany.load(); penghargaanState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -287,7 +287,7 @@ const pengumuman = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
pengumuman.findMany.load(); pengumuman.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -101,38 +101,6 @@ const ApbDesa = proxy({
} }
}, },
}, },
findFirst: {
data: null as Prisma.ApbDesaGetPayload<{
include: { pendapatan: true; belanja: true; pembiayaan: true };
}> | null,
loading: false,
async load(params?: Record<string, any>) {
try {
this.loading = true;
// ✅ request ke endpoint find-first
const res = await ApiFetch.api.ekonomi.pendapatanaslidesa.apbdesa[
"find-first"
].get({ query: params || {} });
if (res.status === 200 && res.data?.success) {
this.data = res.data.data ?? null;
} else {
this.data = null;
toast.error(res.data?.message || "Gagal memuat data pertama APB Desa");
}
} catch (error) {
console.error("Error findFirst APB Desa:", error);
toast.error("Gagal memuat data APB Desa pertama");
this.data = null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
},
},
update: { update: {
id: "", id: "",
form: { ...ApbDesaDefaultForm }, form: { ...ApbDesaDefaultForm },

View File

@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
demografiPekerjaan.create.form = { ...defaultForm }; demografiPekerjaan.create.form = { ...defaultForm };
demografiPekerjaan.findMany.load(); demografiPekerjaan.findMany.load();
return id; return id;

View File

@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
jumlahPendudukMiskin.create.form = { jumlahPendudukMiskin.create.form = {
year: 0, year: 0,
totalPoorPopulation: 0, totalPoorPopulation: 0,

View File

@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.id; const id = res.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
jumlahPengangguran.create.form = { ...jumlahPengangguranForm }; jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
jumlahPengangguran.findMany.load(); jumlahPengangguran.findMany.load();
return id; return id;

View File

@@ -47,7 +47,7 @@ const lowonganKerjaState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
lowonganKerjaState.create.loading = false; lowonganKerjaState.create.loading = false;
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
programKemiskinanState.findMany.load(); programKemiskinanState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
grafikSektorUnggulan.create.form = { grafikSektorUnggulan.create.form = {
name: "", name: "",
description: "", description: "",

View File

@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
grafikBerdasarkanUsiaKerjaNganggur.create.form = { grafikBerdasarkanUsiaKerjaNganggur.create.form = {
usia18_25: "", usia18_25: "",
usia26_35: "", usia26_35: "",
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
grafikBerdasarkanPendidikan.create.form = { grafikBerdasarkanPendidikan.create.form = {
SD: "", SD: "",
SMP: "", SMP: "",

View File

@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
desaDigitalState.findMany.load(); desaDigitalState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
infoTeknoState.findMany.load(); infoTeknoState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -6,9 +6,9 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(5, "Nama minimal 5 karakter"), name: z.string().min(1, "Nama minimal 1 karakter"),
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"), deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"), slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
icon: z.string().min(1, "Icon minimal 1 karakter"), icon: z.string().min(1, "Icon minimal 1 karakter"),
}); });
@@ -29,33 +29,26 @@ const programKreatifState = proxy({
const err = `[${cek.error.issues const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`) .map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; .join("\n")}] required`;
toast.error(err); return toast.error(err);
return false; // ⬅️ ini penting
} }
try { try {
programKreatifState.create.loading = true; programKreatifState.create.loading = true;
const res = await ApiFetch.api.inovasi.programkreatif["create"].post( const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
programKreatifState.create.form programKreatifState.create.form
); );
if (res.status === 200) { if (res.status === 200) {
programKreatifState.findMany.load(); programKreatifState.findMany.load();
toast.success("Sukses menambahkan"); return toast.success("success create");
return true;
} }
console.log(res);
toast.error("failed create"); return toast.error("failed create");
return false;
} catch (error) { } catch (error) {
console.error((error as Error).message); console.log((error as Error).message);
toast.error("Terjadi kesalahan saat create");
return false;
} finally { } finally {
programKreatifState.create.loading = false; programKreatifState.create.loading = false;
} }
} },
}, },
findMany: { findMany: {
data: null as any[] | null, data: null as any[] | null,

View File

@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
].post(keamananLingkunganState.create.form); ].post(keamananLingkunganState.create.form);
if (res.status === 200) { if (res.status === 200) {
keamananLingkunganState.findMany.load(); keamananLingkunganState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
].post(kontakDaruratKeamananState.create.form); ].post(kontakDaruratKeamananState.create.form);
if (res.status === 200) { if (res.status === 200) {
kontakDaruratKeamananState.findMany.load(); kontakDaruratKeamananState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
kontakDaruratItem.findMany.load(); kontakDaruratItem.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
if (res.status === 200) { if (res.status === 200) {
laporanPublikState.findMany.load(); laporanPublikState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);

View File

@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
].post(pencegahanKriminalitasState.create.form); ].post(pencegahanKriminalitasState.create.form);
if (res.status === 200) { if (res.status === 200) {
pencegahanKriminalitasState.findMany.load(); pencegahanKriminalitasState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
tipsKeamananState.findMany.load(); tipsKeamananState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -9,30 +9,29 @@ import { z } from "zod";
// Validasi form // Validasi form
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
informasiUmum: z.object({ informasiUmum: z.object({
fasilitas: z.string().min(1), fasilitas: z.string().min(1, "Fasilitas harus diisi"),
alamat: z.string().min(1), alamat: z.string().min(1, "Alamat harus diisi"),
jamOperasional: z.string().min(1), jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
}), }),
layananUnggulan: z.object({ layananUnggulan: z.object({
content: z.string().min(1), content: z.string().min(1, "Layanan unggulan harus diisi"),
}),
dokterdanTenagaMedis: z.object({
name: z.string().min(1, "Nama dokter harus diisi"),
specialist: z.string().min(1, "Spesialis harus diisi"),
jadwal: z.string().min(1, "Jadwal harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
fasilitasPendukung: z.object({ fasilitasPendukung: z.object({
content: z.string().min(1), content: z.string().min(1, "Fasilitas pendukung harus diisi"),
}), }),
prosedurPendaftaran: z.object({ prosedurPendaftaran: z.object({
content: z.string().min(1), content: z.string().min(1, "Prosedur pendaftaran harus diisi"),
}),
tarifDanLayanan: z.object({
layanan: z.string().min(1, "Layanan harus diisi"),
tarif: z.string().min(1, "Tarif harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
}); });
// Default form kosong // Default form kosong
@@ -46,34 +45,21 @@ const defaultForm = {
layananUnggulan: { layananUnggulan: {
content: "", content: "",
}, },
dokterdanTenagaMedis: {
dokterdanTenagaMedis: [] as string[], // ← array kosong name: "",
tarifDanLayanan: [] as string[], // ← array kosong specialist: "",
jadwal: "",
},
fasilitasPendukung: { fasilitasPendukung: {
content: "", content: "",
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: "", content: "",
}, },
}; tarifDanLayanan: {
layanan: "",
type DokterItem = { tarif: "",
id: string; },
name: string;
specialist: string;
jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
};
type TarifItem = {
id: string;
layanan: string;
tarif: string;
}; };
const fasilitasKesehatan = proxy({ const fasilitasKesehatan = proxy({
@@ -200,26 +186,33 @@ const fasilitasKesehatan = proxy({
const result = await res.json(); const result = await res.json();
const data = result.data; const data = result.data;
this.id = data.id;
this.form = { fasilitasKesehatan.edit.id = data.id;
fasilitasKesehatan.edit.form = {
name: data.name, name: data.name,
informasiUmum: { informasiUmum: {
fasilitas: data.informasiumum.fasilitas, fasilitas: data.informasiumum.fasilitas,
alamat: data.informasiumum.alamat, alamat: data.informasiumum.alamat,
jamOperasional: data.informasiumum.jamOperasional, jamOperasional: data.informasiumum.jamOperasional,
}, },
layananUnggulan: {
content: data.layananunggulan.content,
},
dokterdanTenagaMedis: {
name: data.dokterdantenagamedis.name,
specialist: data.dokterdantenagamedis.specialist,
jadwal: data.dokterdantenagamedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: data.fasilitaspendukung.content, content: data.fasilitaspendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: data.prosedurpendaftaran.content, content: data.prosedurpendaftaran.content,
}, },
// map relasi -> array of IDs tarifDanLayanan: {
layananUnggulan: { layanan: data.tarifdanlayanan.layanan,
content: data.layananunggulan.content, tarif: data.tarifdanlayanan.tarif,
}, },
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
}; };
}, },
async submit() { async submit() {
@@ -245,15 +238,22 @@ const fasilitasKesehatan = proxy({
layananUnggulan: { layananUnggulan: {
content: fasilitasKesehatan.edit.form.layananUnggulan.content, content: fasilitasKesehatan.edit.form.layananUnggulan.content,
}, },
dokterdanTenagaMedis: dokterdanTenagaMedis: {
fasilitasKesehatan.edit.form.dokterdanTenagaMedis, name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
specialist:
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content, content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content, content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
}, },
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan, tarifDanLayanan: {
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
},
}; };
const res = await fetch( const res = await fetch(
@@ -320,26 +320,12 @@ const templateDokterForm = z.object({
name: z.string().min(1, "Nama tidak boleh kosong"), name: z.string().min(1, "Nama tidak boleh kosong"),
specialist: z.string().min(1, "Spesialis tidak boleh kosong"), specialist: z.string().min(1, "Spesialis tidak boleh kosong"),
jadwal: z.string().min(1, "Jadwal tidak boleh kosong"), jadwal: z.string().min(1, "Jadwal tidak boleh kosong"),
jadwalLibur: z.string().min(1, "Jadwal libur tidak boleh kosong"),
jamBukaOperasional: z
.string()
.min(1, "Jam buka operasional tidak boleh kosong"),
jamTutupOperasional: z
.string()
.min(1, "Jam tutup operasional tidak boleh kosong"),
jamBukaLibur: z.string().min(1, "Jam buka libur tidak boleh kosong"),
jamTutupLibur: z.string().min(1, "Jam tutup libur tidak boleh kosong"),
}); });
const defaultDokterForm = { const defaultDokterForm = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
const dokter = proxy({ const dokter = proxy({
@@ -365,7 +351,7 @@ const dokter = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
dokter.create.create.form = { ...defaultDokterForm }; dokter.create.create.form = { ...defaultDokterForm };
dokter.findMany.load(); dokter.findMany.load();
return id; return id;
@@ -477,11 +463,6 @@ const dokter = proxy({
name: data.name, name: data.name,
specialist: data.specialist, specialist: data.specialist,
jadwal: data.jadwal, jadwal: data.jadwal,
jadwalLibur: data.jadwalLibur,
jamBukaOperasional: data.jamBukaOperasional,
jamTutupOperasional: data.jamTutupOperasional,
jamBukaLibur: data.jamBukaLibur,
jamTutupLibur: data.jamTutupLibur,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -506,11 +487,6 @@ const dokter = proxy({
name: this.form.name, name: this.form.name,
specialist: this.form.specialist, specialist: this.form.specialist,
jadwal: this.form.jadwal, jadwal: this.form.jadwal,
jadwalLibur: this.form.jadwalLibur,
jamBukaOperasional: this.form.jamBukaOperasional,
jamTutupOperasional: this.form.jamTutupOperasional,
jamBukaLibur: this.form.jamBukaLibur,
jamTutupLibur: this.form.jamTutupLibur,
}; };
const cek = templateDokterForm.safeParse(formData); const cek = templateDokterForm.safeParse(formData);
@@ -591,255 +567,9 @@ const dokter = proxy({
}, },
}); });
const templateTarifForm = z.object({
tarif: z.string().min(1, "Tarif tidak boleh kosong"),
layanan: z.string().min(1, "Layanan tidak boleh kosong"),
});
const defaultTarifForm = {
tarif: "",
layanan: "",
};
const tarif = proxy({
create: {
form: defaultTarifForm,
loading: false,
async create() {
const cek = templateTarifForm.safeParse(tarif.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
tarif.create.loading = true;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan["create"].post(
tarif.create.form
);
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
tarif.create.form = { ...defaultTarifForm };
tarif.findMany.load();
return id;
}
}
toast.error("failed create");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
tarif.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.TarifDanLayananGetPayload<{
omit: {
isActive: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
tarif.findMany.loading = true; // ✅ Akses langsung via nama path
tarif.findMany.page = page;
tarif.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) {
tarif.findMany.data = res.data.data ?? [];
tarif.findMany.totalPages = res.data.totalPages ?? 1;
} else {
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch tarif dan layanan paginated:", err);
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
} finally {
tarif.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.TarifDanLayananGetPayload<{
omit: { isActive: true };
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`);
if (res.ok) {
const data = await res.json();
tarif.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch tarif dan layanan",
res.statusText
);
tarif.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching tarif dan layanan", error);
tarif.findUnique.data = null;
}
},
},
update: {
id: "",
form: { ...defaultTarifForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/kesehatan/tarifdanlayanan/${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 = {
tarif: data.tarif,
layanan: data.layanan
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading tarif dan layanan:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
tarif: this.form.tarif,
layanan: this.form.layanan
};
const cek = templateTarifForm.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v: any) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await tarif.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data tarif dan layanan");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) {
return toast.warn("ID tidak valid");
}
try {
tarif.delete.loading = true;
const response = await fetch(
`/api/kesehatan/tarifdanlayanan/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(
result.message || "tarif dan layanan berhasil dihapus"
);
await tarif.findMany.load(); // refresh list
} else {
toast.error(
result?.message || "Gagal menghapus tarif dan layanan"
);
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus tarif dan layanan");
} finally {
tarif.delete.loading = false;
}
},
},
});
const fasilitasKesehatanState = proxy({ const fasilitasKesehatanState = proxy({
fasilitasKesehatan, fasilitasKesehatan,
dokter, dokter,
tarif
}); });
export default fasilitasKesehatanState; export default fasilitasKesehatanState;

View File

@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
grafikkepuasan.create.form = { ...defaultForm }; grafikkepuasan.create.form = { ...defaultForm };
grafikkepuasan.findMany.load(); grafikkepuasan.findMany.load();
return id; return id;

View File

@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
persentasekelahiran.create.form = { ...defaultForm }; persentasekelahiran.create.form = { ...defaultForm };
persentasekelahiran.findMany.load(); persentasekelahiran.findMany.load();
return id; return id;

View File

@@ -5,117 +5,58 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
// --- Zod Schema --- const templateapbDesaForm = z.object({
const ApbdesItemSchema = z.object({ name: z.string().min(1, "Judul minimal 1 karakter"),
kode: z.string().min(1, "Kode wajib diisi"), jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
uraian: z.string().min(1, "Uraian wajib diisi"), imageId: z.string().min(1, "File minimal 1"),
anggaran: z.number().min(0), fileId: z.string().min(1, "File minimal 1"),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
}); });
const ApbdesFormSchema = z.object({ const defaultapbdesForm = {
tahun: z.number().int().min(2000, "Tahun tidak valid"), name: "",
imageId: z.string().min(1, "Gambar wajib diunggah"), jumlah: "",
fileId: z.string().min(1, "File wajib diunggah"),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
});
// --- Default Form ---
const defaultApbdesForm = {
tahun: new Date().getFullYear(),
imageId: "", imageId: "",
fileId: "", fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
};
}
// --- State Utama ---
const apbdes = proxy({ const apbdes = proxy({
create: { create: {
form: { ...defaultApbdesForm }, form: { ...defaultapbdesForm },
loading: false, loading: false,
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
const current = this.form.items[index];
if (current) {
const updated = normalizeItem({ ...current, ...updates });
this.form.items[index] = updated;
}
},
reset() {
this.form = { ...defaultApbdesForm };
},
async create() { async create() {
const parsed = ApbdesFormSchema.safeParse(this.form); const cek = templateapbDesaForm.safeParse(apbdes.create.form);
if (!parsed.success) { if (!cek.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`); const err = `[${cek.error.issues
toast.error(`Validasi gagal:\n${errors.join("\n")}`); .map((v) => `${v.path.join(".")}`)
return; .join("\n")}] required`;
return toast.error(err);
} }
try { try {
this.loading = true; apbdes.create.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data); const res = await ApiFetch.api.landingpage.apbdes["create"].post({
...apbdes.create.form,
});
if (res.data?.success) { if (res.status === 200) {
toast.success("APBDes berhasil dibuat");
apbdes.findMany.load(); apbdes.findMany.load();
this.reset(); return toast.success("Data berhasil ditambahkan");
} else {
toast.error(res.data?.message || "Gagal membuat APBDes");
} }
} catch (error: any) { return toast.error("Gagal menambahkan data");
console.error("Create APBDes error:", error); } catch (error) {
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes"); console.log(error);
toast.error("Gagal menambahkan data");
} finally { } finally {
this.loading = false; apbdes.create.loading = false;
} }
}, },
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { image: true; file: true; items: true }; include: {
image: true;
file: true;
};
}>[] }>[]
| null, | null,
page: 1, page: 1,
@@ -123,202 +64,194 @@ const apbdes = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
load: async (page = 1, limit = 10, search = "") => { apbdes.findMany.loading = true; // Use the full path to access the property
apbdes.findMany.loading = true;
apbdes.findMany.page = page; apbdes.findMany.page = page;
apbdes.findMany.search = search; apbdes.findMany.search = search;
try { try {
const query: Record<string, string> = { page: String(page), limit: String(limit) }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query }); const res = await ApiFetch.api.landingpage.apbdes[
"findMany"
if (res.data?.success) { ].get({
query
});
if (res.status === 200 && res.data?.success) {
apbdes.findMany.data = res.data.data || []; apbdes.findMany.data = res.data.data || [];
apbdes.findMany.total = res.data.meta?.total || 0; apbdes.findMany.total = res.data.total || 0;
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1; apbdes.findMany.totalPages = res.data.totalPages || 1;
} else { } else {
console.error("Failed to load pegawai:", res.data?.message);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("FindMany error:", error); console.error("Error loading pegawai:", error);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error("Gagal memuat daftar APBDes");
} finally { } finally {
apbdes.findMany.loading = false; apbdes.findMany.loading = false;
} }
}, },
}, },
findUnique: { findUnique: {
data: null as data: null as Prisma.APBDesGetPayload<{
| Prisma.APBDesGetPayload<{ include: {
include: { image: true; file: true; items: true }; image: true;
}> file: true;
| null, };
loading: false, }> | null,
error: null as string | null,
async load(id: string) { async load(id: string) {
if (!id || id.trim() === '') {
this.data = null;
this.error = "ID tidak valid";
return;
}
this.loading = true;
this.error = null;
try { try {
// Pastikan URL-nya benar const res = await fetch(`/api/landingpage/apbdes/${id}`);
const url = `/api/landingpage/apbdes/${id}`; if (res.ok) {
console.log("🌐 Fetching:", url); const data = await res.json();
apbdes.findUnique.data = data.data ?? null;
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url);
const res = await response.json();
console.log("📦 Response:", res);
if (res.success && res.data) {
this.data = res.data;
} else { } else {
this.data = null; console.error("Failed to fetch data", res.status, res.statusText);
this.error = res.message || "Gagal memuat detail APBDes"; apbdes.findUnique.data = null;
toast.error(this.error);
} }
} catch (error) { } catch (error) {
console.error("❌ FindUnique error:", error); console.error("Error fetching data:", error);
this.data = null; apbdes.findUnique.data = null;
this.error = "Gagal memuat detail APBDes";
toast.error(this.error);
} finally {
this.loading = false;
} }
} },
}, },
delete: { delete: {
loading: false, loading: false,
async byId(id: string) { async byId(id: string) {
if (!id) return toast.warn("ID tidak valid"); if (!id) return toast.warn("ID tidak valid");
try { try {
this.loading = true; apbdes.delete.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
if (res.data?.success) { const response = await fetch(`/api/landingpage/apbdes/del/${id}`, {
toast.success("APBDes berhasil dihapus"); method: "DELETE",
apbdes.findMany.load(); headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "apbdes berhasil dihapus");
await apbdes.findMany.load(); // refresh list
} else { } else {
toast.error(res.data?.message || "Gagal menghapus APBDes"); toast.error(result?.message || "Gagal menghapus apbdes");
} }
} catch (error: any) { } catch (error) {
console.error("Delete error:", error); console.error("Gagal delete:", error);
toast.error(error?.message || "Terjadi kesalahan saat menghapus"); toast.error("Terjadi kesalahan saat menghapus apbdes");
} finally { } finally {
this.loading = false; apbdes.delete.loading = false;
} }
}, },
}, },
edit: { edit: {
id: "", id: "",
form: { ...defaultApbdesForm }, form: { ...defaultapbdesForm },
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) return toast.warn("ID tidak valid"); if (!id) {
toast.warn("ID tidak valid");
return null;
}
try { try {
this.loading = true; apbdes.edit.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
if (res.data?.success) { const response = await fetch(`/api/landingpage/apbdes/${id}`, {
const data = res.data.data; 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.id = data.id;
this.form = { this.form = {
tahun: data.tahun || new Date().getFullYear(), name: data.name,
imageId: data.imageId || "", jumlah: data.jumlah,
fileId: data.fileId || "", imageId: data.imageId,
items: (data.items || []).map((item: any) => ({ fileId: data.fileId,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
}; };
return data; return data;
} else { } else {
throw new Error(res.data?.message || "Gagal memuat data"); throw new Error(result?.message || "Gagal memuat data");
} }
} catch (error: any) { } catch (error) {
console.error("Edit load error:", error); console.error("Error loading apbdes:", error);
toast.error(error.message || "Gagal memuat data untuk diedit"); toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} finally { } finally {
this.loading = false; apbdes.edit.loading = false;
} }
}, },
async update() { async update() {
const parsed = ApbdesFormSchema.safeParse(this.form); const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
if (!parsed.success) { if (!cek.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`); const err = `[${cek.error.issues
toast.error(`Validasi gagal:\n${errors.join("\n")}`); .map((v) => `${v.path.join(".")}`)
return false; .join("\n")}] required`;
return toast.error(err);
} }
try { try {
this.loading = true; apbdes.edit.loading = true;
// Include the ID in the request body const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
const requestData = { method: "PUT",
...parsed.data, headers: {
id: this.id, // Add the ID to the request body "Content-Type": "application/json",
}; },
body: JSON.stringify({
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData); name: this.form.name,
jumlah: this.form.jumlah,
if (res.data?.success) { imageId: this.form.imageId,
toast.success("APBDes berhasil diperbarui"); fileId: this.form.fileId,
apbdes.findMany.load(); }),
});
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 apbdes");
await apbdes.findMany.load(); // refresh list
return true; return true;
} else { } else {
throw new Error(res.data?.message || "Gagal memperbarui APBDes"); throw new Error(result.message || "Gagal mengupdate apbdes");
} }
} catch (error: any) { } catch (error) {
console.error("Update error:", error); console.error("Error updating apbdes:", error);
toast.error(error.message || "Gagal memperbarui APBDes"); toast.error(
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
);
return false; return false;
} finally { } finally {
this.loading = false; apbdes.edit.loading = false;
} }
}, },
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
reset() { reset() {
this.id = ""; apbdes.edit.id = "";
this.form = { ...defaultApbdesForm }; apbdes.edit.form = { ...defaultapbdesForm };
}, },
}, },
}); });
export default apbdes; export default apbdes;

View File

@@ -27,7 +27,7 @@ const programInovasi = proxy({
name: "", name: "",
description: "", description: "",
imageId: "", imageId: "",
link: "", link: ""
} as ProgramInovasiForm, } as ProgramInovasiForm,
loading: false, loading: false,
async create() { async create() {
@@ -53,7 +53,7 @@ const programInovasi = proxy({
].post(formData); ].post(formData);
if (res.status === 200) { if (res.status === 200) {
programInovasi.findMany.load(); programInovasi.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -71,21 +71,20 @@ const programInovasi = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
// Change to arrow function programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.page = page; programInovasi.findMany.page = page;
programInovasi.findMany.search = search; programInovasi.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;
const res = await ApiFetch.api.landingpage.programinovasi[ const res = await ApiFetch.api.landingpage.programinovasi[
"findMany" "findMany"
].get({ ].get({
query, query
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
programInovasi.findMany.data = res.data.data || []; programInovasi.findMany.data = res.data.data || [];
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
@@ -390,10 +389,7 @@ const pejabatDesa = proxy({
try { try {
// Ensure ID is properly encoded in the URL // Ensure ID is properly encoded in the URL
const url = new URL( const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin);
`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`,
window.location.origin
);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -442,19 +438,16 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().nullable().optional(), imageId: z.string().min(1, "Gambar wajib dipilih"),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
icon: z.string().nullable().optional(),
}); });
type MediaSosialForm = { type MediaSosialForm = {
name: string; name: string;
imageId: string | null; // boleh null imageId: string;
iconUrl: string; iconUrl: string;
icon: string | null; // boleh null
}; };
const mediaSosial = proxy({ const mediaSosial = proxy({
create: { create: {
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
@@ -462,10 +455,9 @@ const mediaSosial = proxy({
async create() { async create() {
// Ensure all required fields are non-null // Ensure all required fields are non-null
const formData = { const formData = {
name: mediaSosial.create.form.name ?? "", name: mediaSosial.create.form.name || "",
imageId: mediaSosial.create.form.imageId ?? null, // FIXED imageId: mediaSosial.create.form.imageId || "",
iconUrl: mediaSosial.create.form.iconUrl ?? "", iconUrl: mediaSosial.create.form.iconUrl || "",
icon: mediaSosial.create.form.icon ?? null, // FIXED
}; };
const cek = templateMediaSosial.safeParse(formData); const cek = templateMediaSosial.safeParse(formData);
@@ -482,7 +474,7 @@ const mediaSosial = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
mediaSosial.findMany.load(); mediaSosial.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -500,19 +492,20 @@ const mediaSosial = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
// Change to arrow function mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.page = page; mediaSosial.findMany.page = page;
mediaSosial.findMany.search = search; mediaSosial.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;
const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({ const res = await ApiFetch.api.landingpage.mediasosial[
"findMany"
].get({
query, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
mediaSosial.findMany.data = res.data.data || []; mediaSosial.findMany.data = res.data.data || [];
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
@@ -544,7 +537,7 @@ const mediaSosial = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await fetch(`/api/landingpage/mediasosial/${id}`);
@@ -593,72 +586,66 @@ const mediaSosial = proxy({
}, },
}, },
update: { update: {
id: "", id: "",
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
}
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} }
const result = await response.json(); mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
if (result?.success) { try {
const data = result.data; const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
this.id = data.id; method: "GET",
this.form = { headers: {
name: data.name || "", "Content-Type": "application/json",
imageId: data.imageId || null, },
iconUrl: data.iconUrl || "", });
icon: data.icon || null,
if (!response.ok) {
}; throw new Error(`HTTP error! status: ${response.status}`);
return data; }
} else {
throw new Error( const result = await response.json();
result?.message || "Gagal mengambil data media sosial"
); if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
name: data.name || "",
imageId: data.imageId || "",
iconUrl: data.iconUrl || "",
};
return data;
} else {
throw new Error(result?.message || "Gagal mengambil data media sosial");
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial");
} finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
} }
} catch (error) { },
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial"); async update() {
} finally { const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error if (!cek.success) {
} const err = `[${cek.error.issues
}, .map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
async update() { toast.error(err);
const cek = templateMediaSosial.safeParse(mediaSosial.update.form); return false;
if (!cek.success) { }
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`) try {
.join("\n")}] required`; mediaSosial.update.loading = true;
toast.error(err);
return false; const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, {
}
try {
mediaSosial.update.loading = true;
const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -667,40 +654,38 @@ const mediaSosial = proxy({
name: this.form.name, name: this.form.name,
imageId: this.form.imageId, imageId: this.form.imageId,
iconUrl: this.form.iconUrl, iconUrl: this.form.iconUrl,
icon: this.form.icon,
}), }),
});
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 (!response.ok) {
const errorData = await response.json().catch(() => ({})); if (result.success) {
throw new Error( toast.success("Berhasil update media sosial");
errorData.message || `HTTP error! status: ${response.status}` await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
); );
return false;
} finally {
mediaSosial.update.loading = false;
} }
},
const result = await response.json();
if (result.success) {
toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
);
return false;
} finally {
mediaSosial.update.loading = false;
}
}, },
},
}); });
const profileLandingPageState = proxy({ const profileLandingPageState = proxy({

View File

@@ -93,34 +93,6 @@ const sdgsDesa = proxy({
} }
}, },
}, },
findManyAll: {
data: null as any[] | null,
loading: false,
load: async () => { // Change to arrow function
sdgsDesa.findManyAll.loading = true; // Use the full path to access the property
try {
const query: any = {};
const res = await ApiFetch.api.landingpage.sdgsdesa[
"findManyAll"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
sdgsDesa.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load media sosial:", res.data?.message);
sdgsDesa.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading media sosial:", error);
sdgsDesa.findManyAll.data = [];
} finally {
sdgsDesa.findManyAll.loading = false;
}
},
},
findUnique: { findUnique: {
data: null as Prisma.SdgsDesaGetPayload<{ data: null as Prisma.SdgsDesaGetPayload<{
include: { include: {

View File

@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
dataLingkunganDesaState.findMany.load(); dataLingkunganDesaState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
].post(pengelolaanSampah.create.form); ].post(pengelolaanSampah.create.form);
if (res.status === 200) { if (res.status === 200) {
pengelolaanSampah.findMany.load(); pengelolaanSampah.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
programPenghijauanState.findMany.load(); programPenghijauanState.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -9,32 +9,34 @@ import { z } from "zod";
const templateBeasiswaPendaftar = z.object({ const templateBeasiswaPendaftar = z.object({
namaLengkap: z.string().min(1, "Nama harus diisi"), namaLengkap: z.string().min(1, "Nama harus diisi"),
nis: z.string().min(1, "NIS harus diisi"), nik: z.string().min(1, "NIK harus diisi"),
kelas: z.string().min(1, "Kelas harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"), tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"), tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
namaOrtu: z.string().min(1, "Nama ortu harus diisi"), jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
nik: z.string().min(1, "NIK harus diisi"), kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"), agama: z.string().min(1, "Agama harus diisi"),
penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"), alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
noHp: z.string().min(1, "No HP harus diisi"), noHp: z.string().min(1, "No HP harus diisi"),
email: z.string().min(1, "Email harus diisi"),
statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"),
ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"),
}); });
const defaultBeasiswaPendaftar = { const defaultBeasiswaPendaftar = {
namaLengkap: "", namaLengkap: "",
nis: "", nik: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
namaOrtu: "", jenisKelamin: "",
nik: "", kewarganegaraan: "",
pekerjaanOrtu: "", agama: "",
penghasilan: "", alamatKTP: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
const beasiswaPendaftar = proxy({ const beasiswaPendaftar = proxy({
@@ -198,17 +200,18 @@ const beasiswaPendaftar = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
namaLengkap: data.namaLengkap, namaLengkap: data.namaLengkap,
nis: data.nis, nik: data.nik,
kelas: data.kelas,
jenisKelamin: data.jenisKelamin,
alamatDomisili: data.alamatDomisili,
tempatLahir: data.tempatLahir, tempatLahir: data.tempatLahir,
tanggalLahir: data.tanggalLahir, tanggalLahir: data.tanggalLahir,
namaOrtu: data.namaOrtu, jenisKelamin: data.jenisKelamin,
nik: data.nik, kewarganegaraan: data.kewarganegaraan,
pekerjaanOrtu: data.pekerjaanOrtu, agama: data.agama,
penghasilan: data.penghasilan, alamatKTP: data.alamatKTP,
alamatDomisili: data.alamatDomisili,
noHp: data.noHp, noHp: data.noHp,
email: data.email,
statusPernikahan: data.statusPernikahan,
ukuranBaju: data.ukuranBaju,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -246,17 +249,17 @@ const beasiswaPendaftar = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
namaLengkap: this.form.namaLengkap, namaLengkap: this.form.namaLengkap,
nis: this.form.nis,
kelas: this.form.kelas,
jenisKelamin: this.form.jenisKelamin,
alamatDomisili: this.form.alamatDomisili,
tempatLahir: this.form.tempatLahir,
tanggalLahir: this.form.tanggalLahir,
namaOrtu: this.form.namaOrtu,
nik: this.form.nik, nik: this.form.nik,
pekerjaanOrtu: this.form.pekerjaanOrtu, tanggalLahir: this.form.tanggalLahir,
penghasilan: this.form.penghasilan, jenisKelamin: this.form.jenisKelamin,
kewarganegaraan: this.form.kewarganegaraan,
agama: this.form.agama,
alamatKTP: this.form.alamatKTP,
alamatDomisili: this.form.alamatDomisili,
noHp: this.form.noHp, noHp: this.form.noHp,
email: this.form.email,
statusPernikahan: this.form.statusPernikahan,
ukuranBaju: this.form.ukuranBaju,
}), }),
} }
); );

View File

@@ -42,7 +42,7 @@ const dataPendidikan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
dataPendidikan.create.form = { dataPendidikan.create.form = {
name: "", name: "",
jumlah: "", jumlah: "",

View File

@@ -38,7 +38,7 @@ const daftarInformasiPublik = proxy({
].post(daftarInformasiPublik.create.form); ].post(daftarInformasiPublik.create.form);
if (res.status === 200) { if (res.status === 200) {
daftarInformasiPublik.findMany.load(); daftarInformasiPublik.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("success create");
} }
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {

View File

@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Sukses menambahkan"); toast.success("Success create");
grafikBerdasarkanUmur.create.form = { grafikBerdasarkanUmur.create.form = {
remaja: "", remaja: "",
dewasa: "", dewasa: "",

View File

@@ -1,4 +1,3 @@
/* 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";
@@ -7,207 +6,145 @@ import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z nik: z.string().min(3, "NIK minimal 3 karakter"),
.string() notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
.min(3, "NIK minimal 3 karakter")
.max(16, "NIK maksimal 16 angka"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alamat: z.string().min(3, "Alamat minimal 3 karakter"), alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(), jenisInformasiDimintaId: z.string().nonempty(),
caraMemperolehInformasiId: z.string().nonempty(), caraMemperolehInformasiId: z.string().nonempty(),
caraMemperolehSalinanInformasiId: z.string().nonempty(), caraMemperolehSalinanInformasiId: z.string().nonempty(),
}); })
const jenisInformasiDiminta = proxy({ const jenisInformasiDiminta = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[], | Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load() { async load(){
const res = const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[ if (res.status === 200) {
"find-many" jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
].get(); }
if (res.status === 200) { }
jenisInformasiDiminta.findMany.data = res.data?.data ?? []; }
} })
},
},
});
const caraMemperolehInformasi = proxy({ const caraMemperolehInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehInformasiGetPayload<{ | Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
omit: { isActive: true }; async load() {
}>[], const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
async load() { if (res.status === 200) {
const res = caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[ }
"find-many" }
].get(); }
if (res.status === 200) { })
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehSalinanInformasi = proxy({ const caraMemperolehSalinanInformasi = proxy({
findMany: { findMany: {
data: null as data: null as
| null | null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ | Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
omit: { isActive: true }; async load() {
}>[], const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
async load() { if (res.status === 200) {
const res = caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[ }
"find-many" }
].get(); }
if (res.status === 200) { })
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? []; console.log(caraMemperolehSalinanInformasi)
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
notelp: true; notelp: true;
alamat: true; alamat: true;
email: true; email: true;
jenisInformasiDimintaId: true; jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true; caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true; caraMemperolehSalinanInformasiId: true;
}; };
}>; }>;
const statepermohonanInformasiPublik = proxy({ const statepermohonanInformasiPublik = proxy({
create: { create: {
form: {} as PermohonanInformasiPublikForm, form: {} as PermohonanInformasiPublikForm,
loading: false, loading: false,
async create() { async create(){
const cek = templateForm.safeParse( const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
statepermohonanInformasiPublik.create.form if(!cek.success) {
); const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
if (!cek.success) { .join("\n")}] required`;
toast.error(cek.error.issues.map((i) => i.message).join("\n")); return toast.error(err);
return false; // ⬅️ tambahkan return false }
} try {
statepermohonanInformasiPublik.create.loading = true;
try { const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
statepermohonanInformasiPublik.create.loading = true; if (res.status === 200) {
const res = await ApiFetch.api.ppid.permohonaninformasipublik[ statepermohonanInformasiPublik.findMany.load();
"create" return toast.success("success create");
].post(statepermohonanInformasiPublik.create.form); }
return toast.error("failed create");
if (res.data?.success === false) { } catch (error) {
toast.error(res.data?.message); console.log((error as Error).message);
return false; // ⬅️ gagal } finally {
statepermohonanInformasiPublik.create.loading = false;
}
} }
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
}, },
}, findMany: {
findMany: { data: null as
data: null as | Prisma.PermohonanInformasiPublikGetPayload<{ include: {
| Prisma.PermohonanInformasiPublikGetPayload<{ caraMemperolehSalinanInformasi: true,
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
} }>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: { include: {
caraMemperolehSalinanInformasi: true; jenisInformasiDiminta: true,
jenisInformasiDiminta: true; caraMemperolehInformasi: true,
caraMemperolehInformasi: true; caraMemperolehSalinanInformasi: true,
}; };
}>[] }> | null,
| null, async load(id: string) {
page: 1, try {
totalPages: 1, const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
total: 0, if (res.ok) {
loading: false, const data = await res.json();
search: "", statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
load: async (page = 1, limit = 10, search = "") => { } else {
// Change to arrow function console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findMany.loading = true; // Use the full path to access the property statepermohonanInformasiPublik.findUnique.data = null;
statepermohonanInformasiPublik.findMany.page = page; }
statepermohonanInformasiPublik.findMany.search = search; } catch (error) {
try { console.error("Error fetching program inovasi:", error);
const query: any = { page, limit }; statepermohonanInformasiPublik.findUnique.data = null;
if (search) query.search = search; }
},
const res = await ApiFetch.api.ppid.permohonaninformasipublik[ },
"find-many"
].get({ })
query,
});
if (res.status === 200 && res.data?.success) {
statepermohonanInformasiPublik.findMany.data = res.data.data || [];
statepermohonanInformasiPublik.findMany.total = res.data.total || 0;
statepermohonanInformasiPublik.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
statepermohonanInformasiPublik.findMany.data = [];
statepermohonanInformasiPublik.findMany.total = 0;
statepermohonanInformasiPublik.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading permohonan keberatan informasi:", error);
statepermohonanInformasiPublik.findMany.data = [];
statepermohonanInformasiPublik.findMany.total = 0;
statepermohonanInformasiPublik.findMany.totalPages = 1;
} finally {
statepermohonanInformasiPublik.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true;
caraMemperolehInformasi: true;
caraMemperolehSalinanInformasi: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
});
const statepermohonanInformasiPublikForm = proxy({ const statepermohonanInformasiPublikForm = proxy({
statepermohonanInformasiPublik, statepermohonanInformasiPublik,
jenisInformasiDiminta, jenisInformasiDiminta,
caraMemperolehInformasi, caraMemperolehInformasi,
caraMemperolehSalinanInformasi, caraMemperolehSalinanInformasi,
}); })
export default statepermohonanInformasiPublikForm; export default statepermohonanInformasiPublikForm;

View File

@@ -1,4 +1,3 @@
/* 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";
@@ -6,130 +5,82 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"), email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
.string() alasan: z.string().min(3, "Alasan minimal 3 karakter"),
.min(3, "Nomor Telepon minimal 3 karakter") })
.max(15, "Nomor Telepon maksimal 15 angka"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
});
type PermohonanKeberatanInformasiForm = type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
Prisma.FormulirPermohonanKeberatanGetPayload<{
select: { select: {
name: true; name: true;
email: true; email: true;
notelp: true; notelp: true;
alasan: true; alasan: true;
}; };
}>; }>;
const permohonanKeberatanInformasi = proxy({ const permohonanKeberatanInformasi = proxy({
create: { create: {
form: {} as PermohonanKeberatanInformasiForm, form: {} as PermohonanKeberatanInformasiForm,
loading: false, loading: false,
async create() { async create(){
const cek = templateForm.safeParse( const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
permohonanKeberatanInformasi.create.form if(!cek.success) {
); const err = `[${cek.error.issues
if (!cek.success) { .map((v) => `${v.path.join(".")}`)
toast.error(cek.error.issues.map((i) => i.message).join("\n")); .join("\n")}] required`;
return false; // ⬅️ tambahkan return false return toast.error(err);
} }
try { try {
permohonanKeberatanInformasi.create.loading = true; permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[ const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
"create" if (res.status === 200) {
].post(permohonanKeberatanInformasi.create.form); permohonanKeberatanInformasi.findMany.load();
if (res.data?.success === false) { return toast.success("success create");
toast.error(res.data?.message); }
return false; // ⬅️ gagal return toast.error("failed create");
} } catch (error) {
console.log((error as Error).message);
toast.success("Sukses menambahkan"); } finally {
return true; // ⬅️ sukses permohonanKeberatanInformasi.create.loading = false;
} catch { }
toast.error("Terjadi kesalahan server"); },
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
}, },
}, findMany: {
findMany: { data: null as
data: null as | Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
| null | null,
| Prisma.FormulirPermohonanKeberatanGetPayload<{ async load() {
omit: { isActive: true }; const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
}>[], if (res.status === 200) {
page: 1, permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
totalPages: 1, }
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
permohonanKeberatanInformasi.findMany.loading = true; // Use the full path to access the property
permohonanKeberatanInformasi.findMany.page = page;
permohonanKeberatanInformasi.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
permohonanKeberatanInformasi.findMany.data = res.data.data || [];
permohonanKeberatanInformasi.findMany.total = res.data.total || 0;
permohonanKeberatanInformasi.findMany.totalPages = res.data.totalPages || 1;
} else {
console.error("Failed to load permohonan keberatan informasi:", res.data?.message);
permohonanKeberatanInformasi.findMany.data = [];
permohonanKeberatanInformasi.findMany.total = 0;
permohonanKeberatanInformasi.findMany.totalPages = 1;
} }
} catch (error) {
console.error("Error loading permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findMany.data = [];
permohonanKeberatanInformasi.findMany.total = 0;
permohonanKeberatanInformasi.findMany.totalPages = 1;
} finally {
permohonanKeberatanInformasi.findMany.loading = false;
}
}, },
}, findUnique: {
findUnique: { data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{ omit: {
omit: { isActive: true;
isActive: true; };
}; }> | null,
}> | null, async load(id: string) {
async load(id: string) { try {
try { const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
const res = await fetch( if (res.ok) {
`/api/ppid/permohonankeberataninformasipublik/${id}` const data = await res.json();
); permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
if (res.ok) { } else {
const data = await res.json(); console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = data.data ?? null; permohonanKeberatanInformasi.findUnique.data = null;
} else { }
console.error( } catch (error) {
"Failed to fetch permohonan keberatan informasi:", console.error("Error fetching permohonan keberatan informasi:", error);
res.statusText permohonanKeberatanInformasi.findUnique.data = null;
); }
permohonanKeberatanInformasi.findUnique.data = null; },
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
} }
},
},
}); });
export default permohonanKeberatanInformasi; export default permohonanKeberatanInformasi;

View File

@@ -3,6 +3,9 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
/**
* Schema validasi form ProfilePPID menggunakan Zod.
*/
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
biodata: z.string().min(3, "Biodata minimal 3 karakter"), biodata: z.string().min(3, "Biodata minimal 3 karakter"),
@@ -30,16 +33,25 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
pengalaman: true; pengalaman: true;
unggulan: true; unggulan: true;
imageId: true; imageId: true;
image?: { select: { link: true } }; image?: {
select: {
link: true;
};
};
}; };
}>; }>;
/**
* Improved State Management - Consolidated and more robust
*/
const stateProfilePPID = proxy({ const stateProfilePPID = proxy({
// Consolidated data management
profile: { profile: {
data: null as ProfilePPIDForm | null, data: null as ProfilePPIDForm | null,
loading: false, loading: false,
error: null as string | null, error: null as string | null,
// Single method to load profile data
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
@@ -50,42 +62,52 @@ const stateProfilePPID = proxy({
this.error = null; this.error = null;
try { try {
const res = await fetch(`/api/ppid/profileppid/${id}`); const response = await fetch(`/api/ppid/profileppid/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await res.json(); const result = await response.json();
if (result.success) { if (result.success) {
this.data = result.data; this.data = result.data;
return result.data; return result.data;
} else throw new Error(result.message || "Gagal memuat data profile"); } else {
} catch (err) { throw new Error(result.message || "Gagal mengambil data profile");
const msg = (err as Error).message; }
this.error = msg; } catch (error) {
console.error("Load profile error:", msg); const errorMessage = (error as Error).message;
toast.error("Gagal memuat data profile"); this.error = errorMessage;
console.error("Load profile error:", errorMessage);
toast.error("Terjadi kesalahan saat mengambil data profile");
return null; return null;
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
// Reset profile data
reset() { reset() {
this.data = null; this.data = null;
this.error = null; this.error = null;
this.loading = false; this.loading = false;
}, }
}, },
// Edit form management
editForm: { editForm: {
id: "", id: "",
form: { ...defaultForm }, form: { ...defaultForm },
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
loading: false, loading: false,
error: null as string | null, error: null as string | null,
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
// Initialize form with profile data
initialize(profileData: ProfilePPIDForm) { initialize(profileData: ProfilePPIDForm) {
this.id = profileData.id; this.id = profileData.id;
const data = { this.isReadOnly = false; // Semua data bisa diedit
this.form = {
name: profileData.name || "", name: profileData.name || "",
biodata: profileData.biodata || "", biodata: profileData.biodata || "",
riwayat: profileData.riwayat || "", riwayat: profileData.riwayat || "",
@@ -93,20 +115,23 @@ const stateProfilePPID = proxy({
unggulan: profileData.unggulan || "", unggulan: profileData.unggulan || "",
imageId: profileData.imageId || "", imageId: profileData.imageId || "",
}; };
this.form = { ...data };
this.originalForm = { ...data }; // ✅ Simpan versi original
}, },
// Update form field
updateField(field: keyof typeof defaultForm, value: string) { updateField(field: keyof typeof defaultForm, value: string) {
this.form[field] = value; this.form[field] = value;
}, },
// Submit form
async submit() { async submit() {
const check = templateForm.safeParse(this.form); // Validate form
if (!check.success) { const validation = templateForm.safeParse(this.form);
toast.error(
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ") if (!validation.success) {
); const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false; return false;
} }
@@ -114,54 +139,63 @@ const stateProfilePPID = proxy({
this.error = null; this.error = null;
try { try {
const res = await fetch(`/api/ppid/profileppid/${this.id}`, { const response = await fetch(`/api/ppid/profileppid/${this.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form), body: JSON.stringify(this.form),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const result = await res.json(); 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) { if (result.success) {
toast.success("Berhasil update profile"); toast.success("Berhasil update profile");
this.originalForm = { ...this.form }; // ✅ Update original setelah sukses // Refresh profile data
await stateProfilePPID.profile.load(this.id);
return true; return true;
} else throw new Error(result.message || "Gagal update profile"); } else {
} catch (err) { throw new Error(result.message || "Gagal update profile");
const msg = (err as Error).message; }
this.error = msg; } catch (error) {
toast.error(msg); const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false; return false;
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
// ✅ Tambahan reset ke original data // Reset form
resetToOriginal() {
this.form = { ...this.originalForm };
toast.info("Data dikembalikan ke kondisi awal");
},
reset() { reset() {
this.id = ""; this.id = "";
this.form = { ...defaultForm }; this.form = { ...defaultForm };
this.originalForm = { ...defaultForm };
this.error = null; this.error = null;
this.loading = false; this.loading = false;
}, this.isReadOnly = false;
}
}, },
// Helper methods
async loadForEdit(id: string) { async loadForEdit(id: string) {
const data = await this.profile.load(id); const profileData = await this.profile.load(id);
if (data) this.editForm.initialize(data); if (profileData) {
return data; this.editForm.initialize(profileData);
}
return profileData;
}, },
reset() { reset() {
this.profile.reset(); this.profile.reset();
this.editForm.reset(); this.editForm.reset();
}, }
}); });
export default stateProfilePPID; export default stateProfilePPID;

View File

@@ -90,96 +90,42 @@ const userState = proxy({
} }
}, },
}, },
deleteUser: { updateActive: {
loading: false, loading: false,
async submit(id: string, isActive: boolean) {
async delete(id: string) { this.loading = true;
if (!id) return toast.warn("ID tidak valid");
try { try {
userState.deleteUser.loading = true; const res = await fetch(`/api/user/updt`, {
method: "PUT",
const response = await fetch(`/api/user/delUser/${id}`, { headers: { "Content-Type": "application/json" },
method: "DELETE", body: JSON.stringify({ id, isActive }),
headers: {
"Content-Type": "application/json",
},
}); });
const result = await response.json(); const data = await res.json();
if (res.status === 200 && data.success) {
if (response.ok && result?.success) { toast.success(data.message);
toast.success(result.message || "User berhasil dihapus permanen"); userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
await userState.findMany.load(); // refresh list user setelah delete
} else { } else {
toast.error(result?.message || "Gagal menghapus user"); toast.error(data.message || "Gagal update status user");
} }
} catch (error) { } catch (e) {
console.error("Gagal delete user:", error); console.error(e);
toast.error("Terjadi kesalahan saat menghapus user"); toast.error("Gagal update status user");
} finally { } finally {
userState.deleteUser.loading = false; this.loading = false;
} }
}, },
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.status === 200 && data.success) {
// ✅ Tampilkan pesan yang berbeda jika role berubah
if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else {
toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
}
} catch (e) {
console.error("❌ Error update user:", e);
toast.error("Gagal update user");
return false;
} 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({
@@ -291,7 +237,7 @@ const roleState = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
try { try {
const response = await fetch(`/api/role/${id}`, { const response = await fetch(`/api/role/${id}`, {
method: "GET", method: "GET",
@@ -299,25 +245,31 @@ const roleState = proxy({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json(); const result = await response.json();
if (result?.success) { if (result?.success) {
const data = result.data; const data = result.data;
this.id = data.id;
// langsung set melalui root state, bukan this this.form = {
roleState.update.id = data.id;
roleState.update.form = {
name: data.name, name: data.name,
permissions: data.permissions,
}; };
return data; // Return the loaded data
return data; } else {
throw new Error(result?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("Error loading role:", error); console.error("Error loading role:", error);
toast.error("Gagal memuat data"); toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} }
}, },
async update() { async update() {
const cek = templateRole.safeParse(roleState.update.form); const cek = templateRole.safeParse(roleState.update.form);
if (!cek.success) { if (!cek.success) {
@@ -338,6 +290,7 @@ const roleState = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
name: this.form.name, name: this.form.name,
permissions: this.form.permissions,
}), }),
}); });

View File

@@ -1,103 +1,104 @@
'use client'; 'use client'
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth'; import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core'; import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { PhoneInput } from 'react-international-phone'; import { PhoneInput } from "react-international-phone";
import 'react-international-phone/style.css'; import "react-international-phone/style.css";
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
function Login() { function Login() {
const router = useRouter(); const router = useRouter()
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState("")
const [loading, setLoading] = useState(false); const [isError, setError] = useState(false)
const [loading, setLoading] = useState(false)
// Login.tsx
async function onLogin() { async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
}
try { try {
setLoading(true); setLoading(true);
const response = await apiFetchLogin({ nomor: cleanPhone }); const response = await apiFetchLogin({ nomor: nomor })
if (response && response.success) {
console.log(response); localStorage.setItem("hipmi_auth_code_id", response.kodeId);
toast.success(response.message);
if (!response.success) { router.push("/validasi", { scroll: false });
toast.error(response.message || 'Gagal memproses login');
return;
}
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId);
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else { } else {
// ❌ User baru: langsung ke registrasi (tanpa kodeId) setLoading(false);
router.push('/registrasi'); toast.error(response?.message);
} }
} catch (error) { } catch (error) {
console.error('Error Login:', error); setLoading(false)
toast.error('Terjadi kesalahan saat login'); console.log("Error Login", error)
} finally { toast.error("Terjadi kesalahan saat login")
setLoading(false);
} }
} }
return ( return (
<Stack pos="relative" bg={colors.Bg}> <Stack pos={"relative"} bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}> <Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align="center" justify="center" h="100vh"> <Stack align='center' justify='center' h={"100vh"}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}> <Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align="center" gap="lg"> <Stack align='center' gap={"lg"}>
<Box> <Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}> <Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
Login Login
</Title> </Title>
<Center> <Center>
<Image <Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center> </Center>
</Box> </Box>
<Box w="100%"> <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 <PhoneInput
countrySelectorStyleProps={{ countrySelectorStyleProps={{
buttonStyle: { buttonStyle: {
backgroundColor: colors['blue-button'], backgroundColor: colors['blue-button'],
}, },
}} }}
inputStyle={{ width: '100%' }} inputStyle={{ width: "100%"}}
defaultCountry="id" defaultCountry="id"
value={phone} onChange={(val) => {
onChange={(val) => setPhone(val)} setPhone(val);
}}
/> />
<Box py={20}> {isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20} >
<Button <Button
fullWidth fullWidth
bg={colors['blue-button']} bg={colors['blue-button']}
radius="xl" radius={'xl'}
onClick={onLogin} onClick={onLogin}
loading={loading} loading={loading ? true : false}
> >Masuk
Masuk
</Button> </Button>
</Box> </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> </Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -107,4 +108,4 @@ function Login() {
); );
} }
export default Login; export default Login;

View File

@@ -1,153 +1,113 @@
// app/registrasi/page.tsx /* eslint-disable @typescript-eslint/no-unused-expressions */
'use client'; 'use client'
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto'; import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { PhoneInput } from 'react-international-phone'; import { PhoneInput } from "react-international-phone";
import 'react-international-phone/style.css'; import "react-international-phone/style.css";
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export default function Registrasi() { function Registrasi() {
const router = useRouter(); const [phone, setPhone] = useState("")
const [username, setUsername] = useState(''); const router = useRouter()
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
const [agree, setAgree] = useState(false)
// Ambil data dari localStorage (dari login) async function onRegistarsi() {
useEffect(() => { if (value.length < 5) {
const storedNomor = localStorage.getItem('auth_nomor'); toast.error("Username minimal 5 karakter!");
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
return; return;
} }
setPhone(storedNomor);
}, [router]); if (value.includes(" ")) {
toast.error("Username tidak boleh ada spasi!");
const handleRegister = async () => {
if (!username || username.trim().length < 5) {
toast.error('Username minimal 5 karakter!');
return; return;
} }
if (username.includes(' ')) {
toast.error('Username tidak boleh ada spasi!'); if (!phone) {
toast.error("Nomor telepon wajib diisi!");
return; return;
} }
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor tidak valid!');
return;
}
if (!agree) {
toast.error("Anda harus menyetujui syarat dan ketentuan!");
return;
}
try { try {
setLoading(true); setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId const respone = await apiFetchRegister({ nomor: phone, username: value });
const response = await apiFetchRegister({ username, nomor: cleanPhone });
if (response.success) { if (respone.success) {
// Simpan sementara router.push("/login", { scroll: false });
localStorage.setItem('auth_kodeId', response.kodeId); toast.success(respone.message);
localStorage.setItem('auth_username', username); // simpan username
toast.success('Kode verifikasi dikirim!'); } else {
router.push('/validasi'); // ✅ ke halaman validasi setLoading(false);
toast.error(respone.message);
} }
} catch (error) { } catch (error) {
console.error('Error Registrasi:', error);
toast.error('Gagal mengirim OTP');
} finally {
setLoading(false); setLoading(false);
console.log("Error Registrasi", error);
} }
}; }
return ( return (
<Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh"> <Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<Stack justify="center" align="center" h="80vh"> <Stack justify='center' align='center' h={"80vh"}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}> <Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align="center"> <Stack align='center'>
<Title order={2} fw="bold" c={colors['blue-button']}> <Title order={2} fw={'bold'} c={colors['blue-button']}>
Registrasi Registrasi
</Title> </Title>
<Center> <Center>
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} /> <Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
</Center> </Center>
<Box w="100%"> <Box>
<TextInput <TextInput placeholder='Username'
label="Username" label='Username'
placeholder="Username" maxLength={50}
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={ error={
username.length > 0 && username.length < 5 value.length > 0 && value.length < 5
? 'Minimal 5 karakter!' ? "Minimal 5 karakter !"
: username.includes(' ') : value.includes(" ")
? 'Tidak boleh ada spasi' ? "Tidak boleh ada spasi"
: '' : isValue
? "Masukan username anda"
: ""
} }
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required required
/> />
<Box py={10}>
<Box pt="md"> <Text fz={"sm"} >Nomor Telepon</Text>
<Text fz="sm">Nomor Telepon</Text>
<PhoneInput <PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id" defaultCountry="id"
value={phone} onChange={(val) => {
disabled setPhone(val);
}}
/> />
</Box> </Box>
<Box pb={10}>
<Box pt="md">
<Checkbox <Checkbox
checked={agree} label="Saya menyetujui syarat dan ketentuan yang berlaku"
onChange={(e) => setAgree(e.currentTarget.checked)}
label={
<Text fz="sm">
Saya menyetujui{" "}
<a
href="/terms-of-service"
target="_blank"
style={{
color: colors["blue-button"],
textDecoration: "underline",
fontWeight: 500,
}}
>
syarat dan ketentuan
</a>
</Text>
}
/> />
</Box> </Box>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
<Box pt="xl">
<Button
fullWidth
bg={colors['blue-button']}
radius="xl"
onClick={handleRegister}
loading={loading}
disabled={username.length < 5}
>
Kirim Kode Verifikasi
</Button>
</Box> </Box>
</Box> </Box>
</Stack> </Stack>
@@ -156,4 +116,6 @@ export default function Registrasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Registrasi;

View File

@@ -1,306 +1,31 @@
'use client'; 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
Box,
Button,
Center,
Loader,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
// ✅ Deteksi flow dari cookie via API
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
}
} catch (error) {
console.error('❌ Error getting flow:', error);
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
if (isRegistrationFlow) {
await handleRegistrationVerification();
} else {
await handleLoginVerification();
}
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan.');
return;
}
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
credentials: 'include'
});
const loginData = await loginRes.json();
if (!loginRes.ok) {
toast.error(loginData.message || 'Verifikasi gagal');
return;
}
const { id, name, roleId, isActive } = loginData.user;
authStore.setUser({
id,
name: name || 'User',
roleId: Number(roleId),
});
// ✅ Cleanup setelah login sukses
await cleanupStorage();
if (!isActive) {
window.location.href = '/waiting-room';
return;
}
const redirectPath = getRedirectPath(Number(roleId));
router.replace(redirectPath);
};
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
const cleanupStorage = async () => {
// Clear localStorage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
<Loader size="md" color={colors['blue-button']} />
</Stack>
);
}
if (!nomor) return null;
function Validasi() {
const router = useRouter()
return ( return (
<Stack pos="relative" bg={colors.Bg}> <Stack pos={"relative"} bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}> <Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align="center" justify="center" h="100vh"> <Stack align='center' justify='center' h={"100vh"}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}> <Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align="center" gap="lg"> <Stack align='center' gap={"lg"}>
<Box> <Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}> <Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'} Kode Verifikasi
</Title> </Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box> </Box>
<Box w="100%"> <Box>
<Box mb={20}> <Box mb={10}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold"> <Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
Masukkan Kode Verifikasi <PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box> </Box>
<Box py={20} >
<Button <Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
fullWidth Page
onClick={handleVerify}
loading={loading}
disabled={otp.length < 4}
bg={colors['blue-button']}
radius="xl"
>
Verifikasi
</Button>
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button> </Button>
</Text> </Box>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -308,4 +33,6 @@ export default function Validasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Validasi;

View File

@@ -1,10 +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 { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } 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';
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) { function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -14,31 +14,36 @@ 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} /> 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} /> 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} /> icon: <IconSparkles size={18} stroke={1.8} />,
tooltip: "Layanan inovasi khusus desa"
}, },
{ {
label: "Pelayanan Penduduk Non-Permanent", label: "Pelayanan Penduduk Non-Permanent",
value: "pelayanannonpermanent", 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} /> icon: <IconUsers size={18} stroke={1.8} />,
tooltip: "Pendataan penduduk non-permanent"
}, },
{ {
label: "Ajukan Permohonan", label: "Ajukan Permohonan",
value: "ajukanpermohonan", value: "ajukanpermohonan",
href: "/admin/desa/layanan/ajukan_permohonan", href: "/admin/desa/layanan/ajukan_permohonan",
icon: <IconUsersPlus size={18} stroke={1.8} /> icon: <IconUsersPlus size={18} stroke={1.8} />,
tooltip: "Ajukan permohonan"
} }
]; ];
@@ -72,76 +77,42 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}> <ScrollArea type="auto" offsetScrollbars>
<ScrollArea type="auto" offsetScrollbars> <TabsList
<TabsList p="sm"
p="sm" style={{
style={{ background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", borderRadius: "1rem",
borderRadius: "1rem", boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", display: "flex",
display: "flex", flexWrap: "nowrap",
flexWrap: "nowrap", gap: "0.5rem",
gap: "0.5rem", paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi }}
}} >
> {tabs.map((tab, i) => (
{tabs.map((tab, i) => ( <Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
fontWeight: 600, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
))} </Tooltip>
</TabsList> ))}
</ScrollArea> </TabsList>
</Box> </ScrollArea>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsPanel

View File

@@ -1,10 +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 { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconCategory, IconNews } 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';
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();
@@ -15,13 +15,15 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
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} /> 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} /> icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori berita desa"
}, },
]; ];
@@ -69,39 +71,46 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsTab <Tooltip
key={i} key={i}
value={tab.value} label={tab.tooltip}
leftSection={tab.icon} position="bottom"
style={{ withArrow
fontWeight: 600, transitionProps={{ transition: 'pop', duration: 200 }}
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
> >
{tab.label} <TabsTab
</TabsTab> value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsPanel
key={i} key={i}
value={tab.value} value={tab.value}
style={{ style={{
padding: "1.5rem", padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)", background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem", borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)", boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}} }}
> >
{/* Konten dummy, bisa diganti sesuai routing */} {/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</> <>{children}</>
</TabsPanel> </TabsPanel>
))} ))}
</Tabs> </Tabs>
</Stack > </Stack>
); );
} }

View File

@@ -11,7 +11,7 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Loader 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';
@@ -23,11 +23,6 @@ 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 [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -44,9 +39,6 @@ function EditKategoriBerita() {
setFormData({ setFormData({
name: data.name || '', name: data.name || '',
}); });
setOriginalData({
name: data.name || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading kategori Berita:', error); console.error('Error loading kategori Berita:', error);
@@ -64,16 +56,8 @@ function EditKategoriBerita() {
})); }));
}; };
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// update global state hanya saat submit // update global state hanya saat submit
editState.update.form = { editState.update.form = {
...editState.update.form, ...editState.update.form,
@@ -86,15 +70,14 @@ function EditKategoriBerita() {
} catch (error) { } catch (error) {
console.error('Error updating kategori Berita:', error); console.error('Error updating kategori Berita:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Berita'); toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */} {/* Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -103,6 +86,7 @@ function EditKategoriBerita() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Kategori Berita Edit Kategori Berita
</Title> </Title>
@@ -111,7 +95,7 @@ function EditKategoriBerita() {
{/* Form Wrapper */} {/* Form Wrapper */}
<Paper <Paper
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
shadow="sm" shadow="sm"
@@ -128,17 +112,6 @@ function EditKategoriBerita() {
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -149,7 +122,7 @@ function EditKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -9,18 +9,15 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Loader Tooltip
} from '@mantine/core'; } 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 { toast } from 'react-toastify';
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 [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
@@ -29,23 +26,16 @@ function CreateKategoriBerita() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); await createState.create.create();
try { resetForm();
await createState.create.create(); router.push('/admin/desa/berita/kategori-berita');
resetForm();
router.push('/admin/desa/berita/kategori-berita');
} catch (error) {
console.error('Error creating kategori berita:', error);
toast.error('Gagal menambahkan kategori berita');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -54,6 +44,7 @@ function CreateKategoriBerita() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Kategori Berita Tambah Kategori Berita
</Title> </Title>
@@ -72,23 +63,12 @@ function CreateKategoriBerita() {
<TextInput <TextInput
label="Nama Kategori Berita" label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita" placeholder="Masukkan nama kategori berita"
value={createState.create.form.name || ''} defaultValue={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)} onChange={(e) => (createState.create.form.name = e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -99,7 +79,7 @@ function CreateKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -17,7 +17,8 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -26,7 +27,6 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardBerita from '../../../_state/desa/berita'; import stateDashboardBerita from '../../../_state/desa/berita';
import { useDebouncedValue } from '@mantine/hooks';
function KategoriBerita() { function KategoriBerita() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -49,7 +49,6 @@ function ListKategoriBerita({ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -60,8 +59,8 @@ function ListKategoriBerita({ search }: { search: string }) {
} = listDataState.findMany; } = listDataState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
@@ -83,84 +82,83 @@ function ListKategoriBerita({ search }: { search: string }) {
} }
return ( return (
<Box py={{ base: 'sm', md: 'lg' }}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}> <Group justify="space-between" mb="md">
<Title order={4} lh={1.2}> <Title order={4}>Daftar Kategori Berita</Title>
Daftar Kategori Berita <Tooltip label="Tambah Kategori Berita" withArrow>
</Title> <Button
<Button leftSection={<IconPlus size={18} />}
leftSection={<IconPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() =>
onClick={() => router.push('/admin/desa/berita/kategori-berita/create')
router.push('/admin/desa/berita/kategori-berita/create') }
} >
> Tambah Baru
Tambah Baru </Button>
</Button> </Tooltip>
</Group> </Group>
{/* Desktop Table */} <Box style={{ overflowX: 'auto' }}>
<Box visibleFrom="md"> <Table highlightOnHover>
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w="50%"> <TableTh style={{ width: '10%' }}>No</TableTh>
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text> <TableTh style={{ width: '50%' }}>Nama</TableTh>
</TableTh> <TableTh style={{ width: '20%' }}>Edit</TableTh>
<TableTh w="20%"> <TableTh style={{ width: '20%' }}>Hapus</TableTh>
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="sm" fw={500} lh={1.45} truncate="end"> <Text fz="sm">{index + 1}</Text>
</TableTd>
<TableTd>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name} {item.name}
</Text> </Text>
</TableTd> </TableTd>
<TableTd ta="center"> <TableTd>
<Button <Tooltip label="Edit Kategori Berita" withArrow>
variant="light" <Button
color="green" variant="light"
onClick={() => color="green"
router.push( onClick={() =>
`/admin/desa/berita/kategori-berita/${item.id}` router.push(
) `/admin/desa/berita/kategori-berita/${item.id}`
} )
size="compact-sm" }
> >
<IconEdit size={16} /> <IconEdit size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
<TableTd ta="center"> <TableTd>
<Button <Tooltip label="Hapus Kategori Berita" withArrow>
variant="light" <Button
color="red" variant="light"
disabled={listDataState.delete.loading} color="red"
onClick={() => { disabled={listDataState.delete.loading}
setSelectedId(item.id); onClick={() => {
setModalHapus(true); setSelectedId(item.id);
}} setModalHapus(true);
size="compact-sm" }}
> >
<IconTrash size={16} /> <IconTrash size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={24}> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text color="dimmed">
Tidak ada data kategori berita yang cocok Tidak ada data kategori berita yang cocok
</Text> </Text>
</Center> </Center>
@@ -170,70 +168,22 @@ function ListKategoriBerita({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box flex={1} ml="md">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.45} truncate>
{item.name}
</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/desa/berita/kategori-berita/${item.id}`
)
}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
{totalPages > 1 && ( <Center>
<Center mt={{ base: 'lg', md: 'xl' }}> <Pagination
<Pagination value={page}
value={page} onChange={(newPage) => {
onChange={(newPage) => { load(newPage, 10, search);
load(newPage, 10, search); window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: 'smooth' }); }}
}} total={totalPages}
total={totalPages} mt="md"
color="blue" mb="md"
radius="md" color="blue"
/> radius="md"
</Center> />
)} </Center>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
@@ -246,4 +196,4 @@ function ListKategoriBerita({ search }: { search: string }) {
); );
} }
export default KategoriBerita; export default KategoriBerita;

View File

@@ -1,30 +1,8 @@
'use client' 'use client'
import React from 'react'; import React from 'react';
import LayoutTabsBerita from './_com/layoutTabs'; import LayoutTabsBerita from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsBerita> <LayoutTabsBerita>
{children} {children}

View File

@@ -6,7 +6,6 @@ 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 { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -17,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { import {
@@ -46,17 +45,6 @@ function EditBerita() {
imageId: "", imageId: "",
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita // Load kategori + berita
useEffect(() => { useEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
@@ -76,15 +64,6 @@ function EditBerita() {
imageId: data.imageId || "", imageId: data.imageId || "",
}); });
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
kategoriBeritaId: data.kategoriBeritaId || "",
content: data.content || "",
imageId: data.imageId || "",
imageUrl: data.image?.link || ""
});
if (data?.image?.link) { if (data?.image?.link) {
setPreviewImage(data.image.link); setPreviewImage(data.image.link);
} }
@@ -104,7 +83,6 @@ function EditBerita() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Update global state hanya sekali di sini // Update global state hanya sekali di sini
beritaState.berita.edit.form = { beritaState.berita.edit.form = {
...beritaState.berita.edit.form, ...beritaState.berita.edit.form,
@@ -131,36 +109,23 @@ function EditBerita() {
} catch (error) { } catch (error) {
console.error("Error updating berita:", error); console.error("Error updating berita:", error);
toast.error("Terjadi kesalahan saat memperbarui berita"); toast.error("Terjadi kesalahan saat memperbarui berita");
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
kategoriBeritaId: originalData.kategoriBeritaId,
content: originalData.content,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors["blue-button"]} size={24} /> >
</Button> <IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Berita Edit Berita
</Title> </Title>
@@ -254,14 +219,14 @@ function EditBerita() {
Seret gambar atau klik untuk memilih file Seret gambar atau klik untuk memilih file
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp Maksimal 5MB, format gambar wajib
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar"
@@ -273,24 +238,6 @@ function EditBerita() {
}} }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -310,29 +257,17 @@ function EditBerita() {
{/* Action */} {/* Action */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: '#fff', color: "#fff",
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,14 +1,14 @@
'use client' 'use client'
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import { useProxy } from 'valtio/utils';
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, IconTrash } 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 colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors';
function DetailBerita() { function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
@@ -41,7 +41,7 @@ function DetailBerita() {
const data = beritaState.berita.findUnique.data; const data = beritaState.berita.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
{/* Tombol Back */} {/* Tombol Back */}
<Button <Button
variant="subtle" variant="subtle"
@@ -111,6 +111,7 @@ function DetailBerita() {
{/* Action Button */} {/* Action Button */}
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Berita" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -123,7 +124,9 @@ function DetailBerita() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Berita" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)} onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
@@ -133,6 +136,7 @@ function DetailBerita() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -14,8 +14,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader, Tooltip,
ActionIcon
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
@@ -30,7 +29,6 @@ export default function CreateBerita() {
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 [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => { useShallowEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
@@ -49,48 +47,42 @@ export default function CreateBerita() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { if (!file) {
setIsSubmitting(true); return toast.warn('Silakan pilih file gambar terlebih dahulu');
if (!file) {
return toast.warn('Silakan 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 mengunggah gambar, silakan coba lagi');
}
beritaState.berita.create.form.imageId = uploaded.id;
await beritaState.berita.create.create();
resetForm();
router.push('/admin/desa/berita/list-berita');
} catch (error) {
console.error('Error creating berita:', error);
toast.error('Terjadi kesalahan saat membuat berita');
} finally {
setIsSubmitting(false);
} }
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
beritaState.berita.create.form.imageId = uploaded.id;
await beritaState.berita.create.create();
resetForm();
router.push('/admin/desa/berita/list-berita');
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan tombol kembali */} {/* Header dengan tombol kembali */}
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors['blue-button']} size={24} /> >
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Berita Tambah Berita
</Title> </Title>
@@ -108,7 +100,7 @@ export default function CreateBerita() {
<TextInput <TextInput
label="Judul" label="Judul"
placeholder="Masukkan judul berita" placeholder="Masukkan judul berita"
value={beritaState.berita.create.form.judul} defaultValue={beritaState.berita.create.form.judul}
onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)} onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
required required
/> />
@@ -120,7 +112,7 @@ export default function CreateBerita() {
label: item.name, label: item.name,
value: item.id, value: item.id,
}))} }))}
value={beritaState.berita.create.form.kategoriBeritaId || null} defaultValue={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( const selected = beritaState.kategoriBerita.findMany.data?.find(
@@ -165,7 +157,7 @@ export default function CreateBerita() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -186,7 +178,7 @@ export default function CreateBerita() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar"
@@ -198,26 +190,6 @@ export default function CreateBerita() {
}} }}
loading="lazy" loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -235,17 +207,6 @@ export default function CreateBerita() {
</Box> </Box>
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -256,7 +217,7 @@ export default function CreateBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -16,9 +16,10 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue, 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';
import { useState } from 'react'; import { useState } from 'react';
@@ -45,17 +46,16 @@ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = beritaState.berita.findMany; const { data, page, totalPages, loading, load } = beritaState.berita.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py="md"> <Stack py={10}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
@@ -64,66 +64,66 @@ function ListBerita({ search }: { search: string }) {
const filteredData = data || []; const filteredData = data || [];
return ( return (
<Box py="md"> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Berita</Title> <Title order={4}>Daftar Berita</Title>
<Button <Tooltip label="Tambah Berita" withArrow>
leftSection={<IconCircleDashedPlus size={18} />} <Button
color="blue" leftSection={<IconCircleDashedPlus size={18} />}
variant="light" color="blue"
onClick={() => router.push('/admin/desa/berita/list-berita/create')} variant="light"
> onClick={() => router.push('/admin/desa/berita/list-berita/create')}
Tambah Baru >
</Button> Tambah Baru
</Button>
</Tooltip>
</Group> </Group>
{/* Desktop Table */} <Box style={{ overflowX: 'auto' }}>
<Box visibleFrom="md"> <Table highlightOnHover>
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w="50%">Judul</TableTh> <TableTh style={{ width: '30%' }}>Judul</TableTh>
<TableTh w="30%">Kategori</TableTh> <TableTh style={{ width: '20%' }}>Kategori</TableTh>
<TableTh w="20%">Aksi</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '30%' }}>
<Text fz="md" fw={600} lh={1.45} truncate="end"> <Box w={150}>
{item.judul} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.judul}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '20%' }}>
<Text fz="sm" c="dimmed" lh={1.45}> <Text fz="sm" c="dimmed">
{item.kategoriBerita?.name || '-'} {item.kategoriBerita?.name || '-'}
</Text> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '15%' }}>
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
onClick={() => onClick={() =>
router.push(`/admin/desa/berita/list-berita/${item.id}`) router.push(`/admin/desa/berita/list-berita/${item.id}`)
} }
fz="sm"
px="sm"
h={36}
> >
<IconDeviceImacCog size={18} /> <IconDeviceImacCog size={20} />
<Text ml="xs">Detail</Text> <Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={4}>
<Center py="xl"> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text color="dimmed">
Tidak ada data berita yang cocok Tidak ada data berita yang cocok
</Text> </Text>
</Center> </Center>
@@ -133,52 +133,6 @@ function ListBerita({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
Judul
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.judul}
</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
Kategori
</Text>
<Text fz="sm" lh={1.45} fw={500}>
{item.kategoriBerita?.name || '-'}
</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/desa/berita/list-berita/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data berita yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center> <Center>
@@ -199,4 +153,4 @@ function ListBerita({ search }: { search: string }) {
); );
} }
export default Berita; export default Berita;

View File

@@ -1,303 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
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 {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
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: "",
deskripsi: "",
imagesId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
imagesId: "",
imageUrl: "",
});
// Load kategori + Foto
useEffect(() => {
FotoState.findMany.load();
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.imagesId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
imageUrl: data.imageGalleryFoto?.link || ""
});
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 handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
FotoState.update.form = {
...FotoState.update.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
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");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
imagesId: originalData.imagesId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Foto
</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="Judul Foto"
placeholder="Masukkan judul foto"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Foto
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
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 .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Foto
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Action */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<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)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditFoto;

View File

@@ -1,175 +0,0 @@
'use client';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
import Image from 'next/image';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
function DetailFoto() {
const FotoState = useProxy(stateGallery.foto);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
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 height={500} radius="md" />
</Stack>
);
}
const data = FotoState.findUnique.data;
const imageUrl = data.imageGalleryFoto?.link;
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
// Gunakan max-width agar tidak terlalu lebar di desktop
maw={800}
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
Detail Foto
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul Foto</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{imageUrl ? (
<Box
pos="relative"
style={{
width: '100%',
maxWidth: '600px', // Set a maximum width
margin: '0 auto', // Center the container
aspectRatio: '16/9', // Use 16:9 aspect ratio
borderRadius: 8,
overflow: 'hidden',
position: 'relative'
}}
>
<Image
src={imageUrl}
alt={data.name || 'Gambar Foto'}
fill
style={{
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
loading="lazy"
onError={() => setImageError(true)}
/>
</Box>
) : imageError ? (
<Alert
color="orange"
icon={<IconPhoto size={16} />}
title="Gagal memuat gambar"
radius="md"
>
Gambar tidak dapat ditampilkan.
</Alert>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
{/* Action Buttons */}
<Group gap="sm" justify="flex-start">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/gallery/foto/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus foto ini?"
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -1,228 +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 {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
Image
} 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 [isSubmitting, setIsSubmitting] = useState(false);
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 () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan 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 mengunggah gambar, silakan coba lagi');
}
FotoState.create.form.imagesId = uploaded.id;
await FotoState.create.create();
resetForm();
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error creating foto:', error);
toast.error('Terjadi kesalahan saat membuat foto');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Header Back Button + Title */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Foto
</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">
{/* Judul */}
<TextInput
label="Judul Foto"
placeholder="Masukkan judul Foto"
value={FotoState.create.form.name}
onChange={(e) => {
FotoState.create.form.name = e.currentTarget.value;
}}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Foto
</Text>
<CreateEditor
value={FotoState.create.form.deskripsi}
onChange={(val) => {
FotoState.create.form.deskripsi = val;
}}
/>
</Box>
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<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)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateFoto;

View File

@@ -1,216 +1,160 @@
'use client' "use client";
import colors from '@/con/colors'; import stateFileStorage from "@/state/state-list-image";
import { import {
ActionIcon,
Box, Box,
Button, Card,
Center, Flex,
Group, Group,
Image,
Pagination, Pagination,
Paper, Paper,
Skeleton, SimpleGrid,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
Title TextInput,
} from '@mantine/core'; Title,
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; Tooltip,
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react'; } from "@mantine/core";
import { useRouter } from 'next/navigation'; import { useShallowEffect } from "@mantine/hooks";
import { useState } from 'react'; import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
import { useProxy } from 'valtio/utils'; import { motion } from "framer-motion";
import HeaderSearch from '../../../_com/header'; import toast from "react-simple-toasts";
import stateGallery from '../../../_state/desa/gallery'; import { useSnapshot } from "valtio";
function Foto() { export default function ListImage() {
const [search, setSearch] = useState(""); const { list, total } = useSnapshot(stateFileStorage);
return (
<Box>
<HeaderSearch
title='Foto'
placeholder='Cari judul atau deskripsi foto...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListFoto search={search} />
</Box>
);
}
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, debouncedSearch) stateFileStorage.load();
}, [page, debouncedSearch]) }, []);
const filteredData = data || [] let timeOut: NodeJS.Timer;
if (loading || !data) {
return (
<Stack py={{ base: 'md', md: 'lg' }}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return ( return (
<Box py={{ base: 'md', md: 'lg' }}> <Stack p="lg" gap="lg">
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md"> <Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}> <Title order={2} fw={700}>
<Title order={4} lh={1.2}>Daftar Foto</Title> Galeri Foto
<Button </Title>
leftSection={<IconPlus size={18} />} <TextInput
color="blue" radius="xl"
variant="light" size="md"
onClick={() => router.push('/admin/desa/gallery/foto/create')} placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
> >
Tambah Baru {list.map((v, k) => (
</Button> <Card
</Group> key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
{/* Desktop Table */} <Box>
<Box visibleFrom="md"> <Text size="sm" fw={500} lineClamp={2}>
<Table highlightOnHover> {v.name}
<TableThead> </Text>
<TableTr> </Box>
<TableTh>Judul Foto</TableTh>
<TableTh>Tanggal</TableTh> <Group justify="space-between" align="center" pt="xs">
<TableTh>Deskripsi</TableTh> <Tooltip label="Hapus foto" withArrow>
<TableTh>Aksi</TableTh> <ActionIcon
</TableTr> variant="subtle"
</TableThead> color="red"
<TableTbody> radius="md"
{filteredData.length > 0 ? ( onClick={() => {
filteredData.map((item) => ( stateFileStorage
<TableTr key={item.id}> .del({ id: v.id })
<TableTd> .finally(() => toast("Foto berhasil dihapus"));
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}> }}
{item.name}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</TableTd>
<TableTd>
<Text
fz="sm"
lh={1.45}
truncate="end"
lineClamp={1}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
size="xs"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
> >
<IconDeviceImac size={16} /> <IconTrash size={18} />
<Text ml={5} fz="sm" fw={500}>Detail</Text> </ActionIcon>
</Button> </Tooltip>
</TableTd> </Group>
</TableTr> </Stack>
)) </Card>
) : ( ))}
<TableTr> </SimpleGrid>
<TableTd colSpan={4}> ) : (
<Center py={20}> <Stack align="center" justify="center" py="xl" gap="sm">
<Text c="dimmed" fz="sm" lh={1.4}>Tidak ada foto yang cocok</Text> <Image
</Center> src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
</TableTd> alt="Kosong"
</TableTr> w={120}
)} h={120}
</TableTbody> fit="contain"
</Table> opacity={0.7}
</Box> loading="lazy"
/>
{/* Mobile Card View */} <Text c="dimmed" ta="center">
<Box hiddenFrom="md"> Belum ada foto yang tersedia
<Stack gap="md"> </Text>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="sm" p="md">
<Stack gap="xs">
<Box>
<Text fz="sm" fw={600} lh={1.4}>Judul Foto</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.name}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Tanggal</Text>
<Text fz="sm" fw={500} lh={1.45} c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" fw={500} lh={1.45} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
<Button
variant="light"
color="blue"
size="xs"
fullWidth
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={16} />
<Text ml={5} fz="sm" fw={500}>Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>Tidak ada foto yang cocok</Text>
</Center>
)}
</Stack> </Stack>
</Box> )}
</Paper> </Paper>
<Center> {total && total > 1 && (
<Pagination <Flex justify="center">
value={page} <Pagination
onChange={(newPage) => { total={total}
load(newPage, 10) value={stateFileStorage.page} // Changed from page to value
window.scrollTo({ top: 0, behavior: 'smooth' }) size="md"
}} radius="md"
total={totalPages} withEdges
mt="md" onChange={(page) => {
mb="md" stateFileStorage.load({ page });
color="blue" }}
radius="md" />
/>
</Center> </Flex>
</Box> )}
</Stack>
); );
} }
export default Foto;

View File

@@ -1,29 +1,7 @@
'use client' 'use client'
import { usePathname } from "next/navigation";
import LayoutTabsGallery from "./lib/layoutTabs" import LayoutTabsGallery from "./lib/layoutTabs"
import { Box } from "@mantine/core";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabsGallery> <LayoutTabsGallery>
{children} {children}

View File

@@ -1,10 +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 { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconPhoto, IconVideo } 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';
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()
@@ -14,13 +14,15 @@ 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} /> 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} /> icon: <IconVideo size={18} stroke={1.8} />,
tooltip: "Kelola video galeri desa"
}, },
]; ];
@@ -68,18 +70,25 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsTab <Tooltip
key={i} key={i}
value={tab.value} label={tab.tooltip}
leftSection={tab.icon} position="bottom"
style={{ withArrow
fontWeight: 600, transitionProps={{ transition: 'pop', duration: 200 }}
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
> >
{tab.label} <TabsTab
</TabsTab> value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -4,7 +4,6 @@ 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 { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -12,11 +11,11 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Loader Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack, IconX } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { toast } from 'react-toastify'; 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';
@@ -26,14 +25,6 @@ function EditVideo() {
const videoState = useProxy(stateGallery.video); const videoState = useProxy(stateGallery.video);
const params = useParams(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
linkVideo: "",
});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
deskripsi: '', deskripsi: '',
@@ -54,11 +45,6 @@ function EditVideo() {
deskripsi: data.deskripsi ?? '', deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo ?? '', linkVideo: data.linkVideo ?? '',
}); });
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo ?? '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading video:', error); console.error('Error loading video:', error);
@@ -76,58 +62,43 @@ function EditVideo() {
[] []
); );
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
linkVideo: originalData.linkVideo,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
setIsSubmitting(true); if (!converted) {
const converted = convertYoutubeUrlToEmbed(formData.linkVideo); toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
if (!converted) { return;
toast.error("Link YouTube tidak valid. Pastikan formatnya benar."); }
return;
}
try { try {
videoState.update.form = { videoState.update.form = {
name: formData.name, name: formData.name,
deskripsi: formData.deskripsi, deskripsi: formData.deskripsi,
linkVideo: formData.linkVideo, linkVideo: formData.linkVideo,
}; };
await videoState.update.update(); await videoState.update.update();
toast.success('Video berhasil diperbarui!'); toast.success('Video berhasil diperbarui!');
router.push('/admin/desa/gallery/video'); router.push('/admin/desa/gallery/video');
} catch (error) {
console.error('Error updating video:', error);
toast.error('Terjadi kesalahan saat memperbarui video');
}
} catch (error) { } catch (error) {
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');
} finally {
setIsSubmitting(false);
} }
}; };
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo); const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors['blue-button']} size={24} /> >
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Video Edit Video
</Title> </Title>
@@ -159,7 +130,7 @@ function EditVideo() {
required required
/> />
{embedLink && ( {embedLink && (
<Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}> <Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<iframe <iframe
className="rounded" className="rounded"
width="100%" width="100%"
@@ -167,27 +138,7 @@ function EditVideo() {
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
/> ></iframe>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFormData({
...formData,
linkVideo: '',
});
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -203,17 +154,6 @@ function EditVideo() {
</Box> </Box>
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -224,7 +164,7 @@ function EditVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -2,7 +2,7 @@
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, Group, 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, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -40,7 +40,7 @@ function DetailVideo() {
const data = videoState.findUnique.data; const data = videoState.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -54,7 +54,7 @@ function DetailVideo() {
{/* Detail Video */} {/* Detail Video */}
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "70%" }} w={{ base: "100%", md: "50%" }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -111,6 +111,7 @@ function DetailVideo() {
{/* Tombol Aksi */} {/* Tombol Aksi */}
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Video" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -123,7 +124,9 @@ function DetailVideo() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Video" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -135,6 +138,7 @@ function DetailVideo() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -3,7 +3,6 @@ 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 { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -12,9 +11,9 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack, IconX } 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';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -26,7 +25,6 @@ function CreateVideo() {
const router = useRouter(); const router = useRouter();
const [link, setLink] = useState(''); const [link, setLink] = useState('');
const embedLink = convertYoutubeUrlToEmbed(link); const embedLink = convertYoutubeUrlToEmbed(link);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
videoState.create.form = { videoState.create.form = {
@@ -38,37 +36,31 @@ function CreateVideo() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { if (!embedLink) {
setIsSubmitting(true); toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
if (!embedLink) { return;
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
return;
}
videoState.create.form.linkVideo = embedLink;
await videoState.create.create();
resetForm();
router.push('/admin/desa/gallery/video');
} catch (error) {
console.error("Error creating video:", error);
toast.error("Terjadi kesalahan saat menambahkan video");
} finally {
setIsSubmitting(false);
} }
videoState.create.form.linkVideo = embedLink;
await videoState.create.create();
resetForm();
router.push('/admin/desa/gallery/video');
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */} {/* Header Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors['blue-button']} size={24} /> >
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Video Tambah Video
</Title> </Title>
@@ -88,7 +80,7 @@ function CreateVideo() {
<TextInput <TextInput
label="Judul Video" label="Judul Video"
placeholder="Masukkan judul video" placeholder="Masukkan judul video"
value={videoState.create.form.name} defaultValue={videoState.create.form.name}
onChange={(e) => { onChange={(e) => {
videoState.create.form.name = e.currentTarget.value; videoState.create.form.name = e.currentTarget.value;
}} }}
@@ -99,14 +91,14 @@ function CreateVideo() {
<TextInput <TextInput
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={link} defaultValue={link}
onChange={(e) => setLink(e.currentTarget.value)} onChange={(e) => setLink(e.currentTarget.value)}
required required
/> />
{/* Preview Video */} {/* Preview Video */}
{embedLink && ( {embedLink && (
<Box mt="sm" pos="relative"> <Box mt="sm">
<iframe <iframe
style={{ style={{
borderRadius: 10, borderRadius: 10,
@@ -117,24 +109,7 @@ function CreateVideo() {
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
/> ></iframe>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setLink('');
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
@@ -153,17 +128,6 @@ function CreateVideo() {
{/* Button Submit */} {/* Button Submit */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -174,7 +138,7 @@ function CreateVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -16,9 +16,10 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, 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';
@@ -45,7 +46,6 @@ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -56,77 +56,77 @@ function ListVideo({ search }: { search: string }) {
} = videoState.findMany; } = videoState.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, debouncedSearch) load(page, 10, search)
}, [page, debouncedSearch]) }, [page, search])
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={20}> <Stack py={10}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
) )
} }
return ( return (
<Box py={20}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}> <Group justify="space-between" mb="md">
<Title order={4} lh={1.2}> <Title order={4}>Daftar Video</Title>
Daftar Video <Tooltip label="Tambah Video Baru" withArrow>
</Title> <Button
<Button leftSection={<IconPlus size={18} />}
leftSection={<IconPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() => router.push('/admin/desa/gallery/video/create')}
onClick={() => router.push('/admin/desa/gallery/video/create')} >
> Tambah Baru
Tambah Baru </Button>
</Button> </Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
{/* Desktop Table */} <Table highlightOnHover>
<Box visibleFrom="md">
<Table highlightOnHover w="100%">
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Judul Video</TableTh> <TableTh style={{ width: '25%' }}>Judul Video</TableTh>
<TableTh>Tanggal</TableTh> <TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh>Deskripsi</TableTh> <TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh>Aksi</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '25%' }}>
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}> <Box w={200}>
{item.name} <Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Text> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '20%' }}>
<Text fz="sm" c="dimmed" lh={1.45}> <Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', { {new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
})} })}
</Text> </Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '30%' }}>
<Text fz="sm" lh={1.45} truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} /> <Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '15%' }}>
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)} onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
fz="sm"
px="xs"
> >
<IconDeviceImac size={18} /> <IconDeviceImac size={20} />
<Text ml={5}>Detail</Text> <Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
@@ -135,10 +135,8 @@ function ListVideo({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={24}> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text c="dimmed">Tidak ada video yang cocok</Text>
Tidak ada video yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -146,74 +144,23 @@ function ListVideo({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} p="sm" withBorder radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Judul Video</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Tanggal</Text>
<Text fz="sm" fw={500} lh={1.45}>
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="xs" fw={600} lh={1.4}>Deskripsi</Text>
<Text fz="sm" lineClamp={5} fw={500} lh={1.45} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
<Box>
<Button
variant="light"
color="blue"
fullWidth
onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}
fz="sm"
>
<IconDeviceImac size={18} />
<Text ml={5}>Detail</Text>
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada video yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center>
{totalPages > 1 && ( <Pagination
<Center mt="xl"> value={page}
<Pagination onChange={(newPage) => {
value={page} load(newPage, 10)
onChange={(newPage) => { window.scrollTo({ top: 0, behavior: 'smooth' })
load(newPage, 10) }}
window.scrollTo({ top: 0, behavior: 'smooth' }) total={totalPages}
}} mt="md"
total={totalPages} mb="md"
color="blue" color="blue"
radius="md" radius="md"
/> />
</Center> </Center>
)}
</Box> </Box>
); );
} }
export default Video; export default Video;

View File

@@ -6,12 +6,12 @@ import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
Select, Select,
Stack, Stack,
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';
@@ -24,16 +24,6 @@ function EditAjukanPermohonan() {
const params = useParams(); const params = useParams();
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan); const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
nama: "",
nik: "",
alamat: "",
nomorKk: "",
kategoriId: "",
});
// State lokal form // State lokal form
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nama: '', nama: '',
@@ -61,13 +51,6 @@ function EditAjukanPermohonan() {
nomorKk: data.nomorKk || '', nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '', kategoriId: data.kategoriId || '',
}); });
setOriginalData({
nama: data.nama || '',
nik: data.nik || '',
alamat: data.alamat || '',
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading ajukan:', error); console.error('Error loading ajukan:', error);
@@ -86,20 +69,8 @@ function EditAjukanPermohonan() {
})); }));
}; };
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
nik: originalData.nik,
alamat: originalData.alamat,
nomorKk: originalData.nomorKk,
kategoriId: originalData.kategoriId,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
stateAjukan.edit.form = { stateAjukan.edit.form = {
...stateAjukan.edit.form, ...stateAjukan.edit.form,
...formData, ...formData,
@@ -109,18 +80,18 @@ function EditAjukanPermohonan() {
} catch (error) { } catch (error) {
console.error('Error updating ajukan:', error); console.error('Error updating ajukan:', error);
toast.error('Terjadi kesalahan saat memperbarui ajukan'); toast.error('Terjadi kesalahan saat memperbarui ajukan');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button */} {/* Back Button */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Ajukan Permohonan Edit Ajukan Permohonan
</Title> </Title>
@@ -185,17 +156,6 @@ function EditAjukanPermohonan() {
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -206,7 +166,7 @@ function EditAjukanPermohonan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -9,7 +9,8 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -48,7 +49,7 @@ function DetailAjukanPermohonan() {
const data = ajukanPermohonanState.findUnique.data; const data = ajukanPermohonanState.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -61,7 +62,7 @@ function DetailAjukanPermohonan() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '70%' }} w={{ base: '100%', md: '60%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -120,6 +121,7 @@ function DetailAjukanPermohonan() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -133,7 +135,9 @@ function DetailAjukanPermohonan() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -147,6 +151,7 @@ function DetailAjukanPermohonan() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -24,7 +24,6 @@ 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 stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function AjukanPermohonan() { function AjukanPermohonan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -45,7 +44,6 @@ function AjukanPermohonan() {
function ListAjukanPermohonan({ search }: { search: string }) { function ListAjukanPermohonan({ search }: { search: string }) {
const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan); const AjukanPermohonanState = useProxy(stateLayananDesa.ajukanPermohonan);
const router = useRouter(); const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000); // 500ms delay
const { const {
data, data,
@@ -56,56 +54,58 @@ function ListAjukanPermohonan({ search }: { search: string }) {
} = AjukanPermohonanState.findMany; } = AjukanPermohonanState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
// Loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={{ base: 'sm', md: 'md' }}> <Stack py={10}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={{ base: 'sm', md: 'md' }}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Title order={2} lh={1.2} mb={{ base: 'md', md: 'lg' }}> <Title order={4}>List Ajukan Permohonan</Title>
List Ajukan Permohonan <Box style={{ overflowX: "auto" }}>
</Title> <Table highlightOnHover>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover miw={0}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh fz="sm" fw={600} lh={1.4}>Nama</TableTh> <TableTh style={{ width: '30%' }}>Nama</TableTh>
<TableTh fz="sm" fw={600} lh={1.4}>Alamat</TableTh> <TableTh style={{ width: '45%' }}>Alamat</TableTh>
<TableTh fz="sm" fw={600} lh={1.4}>NIK</TableTh> <TableTh style={{ width: '15%' }}>NIK</TableTh>
<TableTh fz="sm" fw={600} lh={1.4}>Aksi</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{data.length > 0 ? ( {data.length > 0 ? (
data.map((item) => ( data.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '30%' }}>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}> <Box w={200}>
{item.nama} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.nama}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '45%' }}>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}> <Box w={200}>
{item.alamat} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.alamat}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '45%' }}>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}> <Box w={200}>
{item.nik} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.nik}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '15%' }}>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
@@ -123,11 +123,9 @@ function ListAjukanPermohonan({ search }: { search: string }) {
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={3}>
<Center py="xl"> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text color="dimmed">Tidak ada data ajukan permohonan yang cocok</Text>
Tidak ada data ajukan permohonan yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -135,71 +133,23 @@ function ListAjukanPermohonan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Card View */}
<Box hiddenFrom="md">
<Stack gap="md">
{data.length > 0 ? (
data.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md" shadow="xs">
<Stack gap={'xs'}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.nama}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Alamat</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.alamat}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>NIK</Text>
<Text fz="sm" fw={500} lh={1.5}>{item.nik}</Text>
</Box>
<Box>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/desa/layanan/ajukan_permohonan/${item.id}`)
}
fullWidth
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ajukan permohonan yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper> </Paper>
<Center>
{totalPages > 1 && ( <Pagination
<Center mt="md"> value={page}
<Pagination onChange={(newPage) => {
value={page} load(newPage, 10, search);
onChange={(newPage) => { window.scrollTo({ top: 0, behavior: 'smooth' });
load(newPage, 10, search); }}
window.scrollTo({ top: 0, behavior: 'smooth' }); total={totalPages}
}} mt="md"
total={totalPages} mb="md"
color="blue" color="blue"
radius="md" radius="md"
/> />
</Center> </Center>
)}
</Box> </Box>
); );
} }
export default AjukanPermohonan; export default AjukanPermohonan;

View File

@@ -1,31 +1,10 @@
'use client' 'use client'
import { usePathname } from "next/navigation";
import LayoutTabsLayanan from "../_com/layoutTabLayanan"; import LayoutTabsLayanan from "../_com/layoutTabLayanan";
import { Box } from "@mantine/core";
export default function Layout({children} : {children: React.ReactNode}) { export default function Layout({children} : {children: React.ReactNode}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/layanan/semua → panjang 5 → list
// - /darmasaba/desa/layanan/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/layanan/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return ( return (
<Box> <LayoutTabsLayanan>
{children} {children}
</Box> </LayoutTabsLayanan>
); )
}
return (
<LayoutTabsLayanan>
{children}
</LayoutTabsLayanan>
);
} }

View File

@@ -8,12 +8,12 @@ import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
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';
@@ -33,14 +33,6 @@ function EditPelayananPendudukNonPermanent() {
deskripsi: '', deskripsi: '',
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
});
// Load data sekali dari backend // Load data sekali dari backend
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -54,10 +46,6 @@ function EditPelayananPendudukNonPermanent() {
name: data.name || '', name: data.name || '',
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || '',
}); });
setOriginalData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading data:', error); console.error('Error loading data:', error);
@@ -70,55 +58,41 @@ function EditPelayananPendudukNonPermanent() {
const handleChange = const handleChange =
(field: keyof typeof formData) => (field: keyof typeof formData) =>
(value: string) => { (value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
[field]: value, [field]: value,
})); }));
}; };
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { if (!statePendudukNonPermanent.findById.data) return;
setIsSubmitting(true);
if (!statePendudukNonPermanent.findById.data) return;
// Update global state hanya di submit // Update global state hanya di submit
const updated = { const updated = {
...statePendudukNonPermanent.findById.data, ...statePendudukNonPermanent.findById.data,
name: formData.name, name: formData.name,
deskripsi: formData.deskripsi, deskripsi: formData.deskripsi,
}; };
await statePendudukNonPermanent.update.update(updated); await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent'); router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memuat data pelayanan penduduk non permanent');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box>
<Stack gap="xs"> <Stack gap="xs">
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors['blue-button']} size={24} /> >
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Pelayanan Penduduk Non Permanent Edit Pelayanan Penduduk Non Permanent
</Title> </Title>
@@ -156,30 +130,24 @@ function EditPelayananPendudukNonPermanent() {
</Box> </Box>
{/* Submit Button */} {/* Submit Button */}
<Group justify="right"> <Group>
{/* Tombol Batal */}
<Button <Button
variant="outline" bg={colors['blue-button']}
color="gray" onClick={handleSubmit}
radius="md" loading={statePendudukNonPermanent.update.loading}
size="md" disabled={!formData.name}
onClick={handleResetForm}
> >
Batal {statePendudukNonPermanent.update.loading
? 'Menyimpan...'
: 'Simpan Perubahan'}
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} variant="outline"
radius="md" onClick={() => router.back()}
size="md" disabled={statePendudukNonPermanent.update.loading}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -11,7 +11,8 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
Title Title,
Tooltip,
} from '@mantine/core'; } 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';
@@ -45,28 +46,26 @@ function PelayananPendudukNonPermanent() {
{/* Header */} {/* Header */}
<Grid align="center"> <Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title <Title order={3} c={colors['blue-button']}>
order={3}
lh={1.2}
c={colors['blue-button']}
>
Preview Pelayanan Penduduk Non Permanen Preview Pelayanan Penduduk Non Permanen
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Tooltip label="Edit Data Pelayanan" withArrow>
c="green" <Button
variant="light" c="green"
leftSection={<IconEdit size={18} stroke={2} />} variant="light"
radius="md" leftSection={<IconEdit size={18} stroke={2} />}
onClick={() => radius="md"
router.push( onClick={() =>
`/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}` router.push(
) `/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
} )
> }
Edit >
</Button> Edit
</Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -74,14 +73,14 @@ function PelayananPendudukNonPermanent() {
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs"> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl"> <Box px={{ base: 0, md: 50 }} pb="xl">
<Center> <Center>
<Title <Text
order={2}
lh={1.2}
ta="center" ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
> >
{data.name} {data.name}
</Title> </Text>
</Center> </Center>
<Divider my="md" color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
@@ -90,11 +89,9 @@ function PelayananPendudukNonPermanent() {
<Text <Text
py={10} py={10}
ta="justify" ta="justify"
fz={{ base: 'sm', md: 'md' }} fz={{ base: '1rem', md: '1.2rem' }}
lh={{ base: 1.5, md: 1.55 }}
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }} style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
</Box> </Box>
</Box> </Box>
@@ -104,4 +101,4 @@ function PelayananPendudukNonPermanent() {
); );
} }
export default PelayananPendudukNonPermanent; export default PelayananPendudukNonPermanent;

View File

@@ -8,12 +8,12 @@ import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
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';
@@ -35,21 +35,13 @@ function EditPelayananPerizinanBerusaha() {
link: '', link: '',
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
id: '',
name: '',
deskripsi: '',
link: '',
});
// Load data detail // Load data detail
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
toast.error("ID tidak valid"); toast.error("ID tidak valid");
return; return;
} }
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -61,12 +53,6 @@ function EditPelayananPerizinanBerusaha() {
deskripsi: data.deskripsi || "", deskripsi: data.deskripsi || "",
link: data.link || "", link: data.link || "",
}); });
setOriginalData({
id: data.id,
name: data.name || "",
deskripsi: data.deskripsi || "",
link: data.link || "",
});
} else { } else {
toast.error("Data tidak ditemukan"); toast.error("Data tidak ditemukan");
} }
@@ -77,10 +63,10 @@ function EditPelayananPerizinanBerusaha() {
setLoading(false); setLoading(false);
} }
}; };
loadData(); loadData();
}, [id]); }, [id]);
const handleChange = const handleChange =
(field: keyof typeof formData) => (field: keyof typeof formData) =>
@@ -91,26 +77,13 @@ function EditPelayananPerizinanBerusaha() {
})); }));
}; };
const handleResetForm = () => {
setFormData({
id: originalData.id,
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
await state.update.update(formData); await state.update.update(formData);
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha'); router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
} catch (error) { } catch (error) {
console.error('Error updating pelayanan perizinan berusaha:', error); console.error('Error updating pelayanan perizinan berusaha:', error);
toast.error('Terjadi kesalahan saat update data'); toast.error('Terjadi kesalahan saat update data');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -123,18 +96,20 @@ function EditPelayananPerizinanBerusaha() {
} }
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box>
<Stack gap="xs"> <Stack gap="xs">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors['blue-button']} size={24} /> >
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Pelayanan Perizinan Berusaha Edit Pelayanan Perizinan Berusaha
</Title> </Title>
@@ -175,30 +150,22 @@ function EditPelayananPerizinanBerusaha() {
/> />
</Box> </Box>
<Group justify="right"> <Group>
{/* Tombol Batal */}
<Button <Button
variant="outline" bg={colors['blue-button']}
color="gray" onClick={handleSubmit}
radius="md" loading={state.update.loading}
size="md" disabled={!formData.name}
onClick={handleResetForm}
> >
Batal {state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} variant="outline"
radius="md" onClick={() => router.back()}
size="md" disabled={state.update.loading}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Batal
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -16,13 +16,14 @@ import {
StepperCompleted, StepperCompleted,
StepperStep, StepperStep,
Text, Text,
Title Title,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useProxy } from 'valtio/utils';
import { useRouter } from 'next/navigation';
function PerizinanBerusaha() { function PerizinanBerusaha() {
const router = useRouter(); const router = useRouter();
@@ -41,7 +42,8 @@ function PerizinanBerusaha() {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const id = 'edit'; // You should get the ID from your router query or params
const id = 'edit'; // Replace with actual ID or get from URL params
await pelayananPerizinanBerusaha.findById.load(id); await pelayananPerizinanBerusaha.findById.load(id);
} catch (err) { } catch (err) {
setError('Gagal memuat data'); setError('Gagal memuat data');
@@ -65,7 +67,7 @@ function PerizinanBerusaha() {
if (error || !pelayananPerizinanBerusaha.findById.data) { if (error || !pelayananPerizinanBerusaha.findById.data) {
return ( return (
<Center h={200}> <Center h={200}>
<Text c="dimmed">{error || 'Data tidak ditemukan'}</Text> <Text>{error || 'Data tidak ditemukan'}</Text>
</Center> </Center>
); );
} }
@@ -78,24 +80,26 @@ function PerizinanBerusaha() {
{/* Header */} {/* Header */}
<Grid align="center"> <Grid align="center">
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']} lh={1.2}> <Title order={3} c={colors['blue-button']}>
Preview Pelayanan Perizinan Berusaha Preview Pelayanan Perizinan Berusaha
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Tooltip label="Edit Data Perizinan" withArrow>
c="green" <Button
variant="light" c="green"
leftSection={<IconEdit size={18} stroke={2} />} variant="light"
radius="md" leftSection={<IconEdit size={18} stroke={2} />}
onClick={() => radius="md"
router.push( onClick={() =>
`/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}` router.push(
) `/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
} )
> }
Edit >
</Button> Edit
</Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -103,40 +107,38 @@ function PerizinanBerusaha() {
<Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs"> <Paper p="xl" bg={'white'} withBorder radius="md" shadow="xs">
<Box px={{ base: 0, md: 50 }} pb="xl"> <Box px={{ base: 0, md: 50 }} pb="xl">
<Center> <Center>
<Title <Text
order={3}
ta="center" ta="center"
fz={{ base: '1.2rem', md: '1.8rem' }}
fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
lh={1.15}
> >
{data.name} {data.name}
</Title> </Text>
</Center> </Center>
<Divider my="md" color={colors['blue-button']} /> <Divider my="md" color={colors['blue-button']} />
<Box mt="lg"> <Box mt="lg">
<Text <Text
py="xs" py={10}
ta={{ base: "left", md: "justify" }} ta="justify"
fz={{ base: 'sm', md: 'md' }} fz={{ base: '1rem', md: '1.2rem' }}
lh={1.55}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
<Text <Text
py="xs" py={10}
fz={{ base: 'sm', md: 'md' }} fz={{ base: '1rem', md: '1.2rem' }}
fw={700} fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5}
> >
Proses pendaftaran NIB melalui OSS mencakup beberapa langkah Proses pendaftaran NIB melalui OSS mencakup beberapa langkah
umum: umum:
</Text> </Text>
<Box p="xl" w="100%" visibleFrom='md'> <Box p="xl" w="100%">
<Stepper <Stepper
active={active} active={active}
onStepClick={setActive} onStepClick={setActive}
@@ -144,115 +146,28 @@ function PerizinanBerusaha() {
styles={{ styles={{
separator: { marginLeft: 25 }, separator: { marginLeft: 25 },
step: { padding: '12px 0' }, step: { padding: '12px 0' },
stepLabel: {
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 700,
lineHeight: 1.4,
},
stepDescription: {
fontSize: 'var(--mantine-font-size-xs)',
lineHeight: 1.4,
},
}} }}
> >
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun"> <StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
<Text fz="sm" lh={1.5}> Pendaftaran akun pada portal OSS
Pendaftaran akun pada portal OSS
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan"> <StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
<Text fz="sm" lh={1.5}> Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
Mengisi informasi perusahaan, termasuk data pemegang saham, alamat perusahaan, dan lainnya
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI"> <StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
<Text fz="sm" lh={1.5}> Memilih KBLI dengan jenis usaha yang akan didaftarkan
Memilih KBLI dengan jenis usaha yang akan didaftarkan
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen"> <StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
<Text fz="sm" lh={1.5}> Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
Mengunggah dokumen-dokumen yang diperlukan, seperti akta pendirian perusahaan, surat izin usaha, dan dokumen lainnya sesuai dengan ketentuan yang berlaku
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan"> <StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
<Text fz="sm" lh={1.5}> Proses verifikasi dan persetujuan oleh instansi terkait
Proses verifikasi dan persetujuan oleh instansi terkait
</Text>
</StepperStep> </StepperStep>
<StepperStep label="Langkah Keenam" description="Penerimaan NIB"> <StepperStep label="Langkah Keenam" description="Penerimaan NIB">
<Text fz="sm" lh={1.5}> Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
Jika proses sebelumnya berhasil, perusahaan akan menerima NIB sebagai identitas resmi usaha anda
</Text>
</StepperStep> </StepperStep>
<StepperCompleted> <StepperCompleted>
<Text fz="sm" lh={1.5}> Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
Selesai, anda telah mengikuti proses pendaftaran NIB melalui OSS
</Text>
</StepperCompleted>
</Stepper>
<Group justify="center" mt="xl">
<Button variant="default" onClick={prevStep}>
Back
</Button>
<Button onClick={nextStep}>Next step</Button>
</Group>
</Box>
<Box p="xl" w="100%" hiddenFrom='md'>
<Stepper
active={active}
onStepClick={setActive}
orientation="vertical"
styles={{
separator: { marginLeft: 25 },
step: { padding: '12px 0' },
stepLabel: {
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 700,
lineHeight: 1.4,
},
stepDescription: {
fontSize: 'var(--mantine-font-size-xs)',
lineHeight: 1.4,
},
}}
>
<StepperStep label="Langkah Pertama" description="Pendaftaran Akun">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Kedua" description="Pengisian Data Perusahaan">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Ketiga" description="Pemilihan KBLI">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Keempat" description="Pengunggahan Dokumen">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Kelima" description="Verifikasi dan Persetujuan">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperStep label="Langkah Keenam" description="Penerimaan NIB">
<Text fz="sm" lh={1.5}>
</Text>
</StepperStep>
<StepperCompleted>
<Text fz="sm" lh={1.5}>
</Text>
</StepperCompleted> </StepperCompleted>
</Stepper> </Stepper>
@@ -265,10 +180,9 @@ function PerizinanBerusaha() {
</Box> </Box>
<Text <Text
py="md" py={35}
ta={{ base: "left", md: "justify" }} ta="justify"
fz={{ base: 'sm', md: 'md' }} fz={{ base: '1rem', md: '1.2rem' }}
lh={1.55}
> >
Penting untuk diingat bahwa prosedur dan persyaratan dapat Penting untuk diingat bahwa prosedur dan persyaratan dapat
berubah seiring waktu. Untuk informasi yang lebih akurat dan berubah seiring waktu. Untuk informasi yang lebih akurat dan
@@ -292,4 +206,5 @@ function PerizinanBerusaha() {
); );
} }
export default PerizinanBerusaha; export default PerizinanBerusaha;

View File

@@ -1,280 +1,127 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ 'use client'
'use client';
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 ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
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';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import * as React from 'react'; import { useProxy } from 'valtio/utils';
// 🔹 Types
interface FormData {
name: string;
deskripsi: string;
imageId: string;
image2Id: string;
imageUrl: string;
image2Url: string;
}
interface FileUploaderProps {
title: string;
file: File | null;
setFile: React.Dispatch<React.SetStateAction<File | null>>;
preview: string | null;
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
}
// 🔹 File Uploader Component
const FileUploader: React.FC<FileUploaderProps> = ({
title,
file,
setFile,
preview,
setPreview
}) => {
const handleDrop = (files: File[]) => {
const selected = files[0];
if (selected) {
setFile(selected);
setPreview(URL.createObjectURL(selected));
}
};
const handleRemove = () => {
setPreview(null);
setFile(null);
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Text fw="bold" fz="sm" mb={6}>
{title}
</Text>
<Dropzone
onDrop={handleDrop}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={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 .png, .jpg, .jpeg, .webp
</Text>
</Stack>
</Group>
</Dropzone>
{preview && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={preview}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={handleRemove}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
);
};
// 🔹 Main Component
function EditSuratKeterangan() { function EditSuratKeterangan() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
// 🧩 State // state lokal untuk form
const [formData, setFormData] = useState<FormData>({ const [formData, setFormData] = useState({
name: '', name: '',
deskripsi: '', deskripsi: '',
imageId: '', imageId: '',
image2Id: '', image2Id: '',
imageUrl: '',
image2Url: '',
}); });
const [originalData, setOriginalData] = useState<FormData>(formData);
// state file upload
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [file2, setFile2] = useState<File | null>(null); const [file2, setFile2] = useState<File | null>(null);
// state preview gambar
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 [isSubmitting, setIsSubmitting] = useState(false);
// 🧭 Load Initial Data // load data awal
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 stateLayananDesa.suratKeterangan.edit.load(id); const data = await stateSurat.edit.load(id);
if (!data) return; if (!data) return;
const mapped: FormData = { setFormData((prev) => ({
name: data.name || '', ...prev,
deskripsi: data.deskripsi || '', ...{
imageId: data.imageId || '', name: prev.name || data.name || "",
image2Id: data.image2Id || '', deskripsi: prev.deskripsi || data.deskripsi || "",
imageUrl: data.image?.link || '', imageId: prev.imageId || data.imageId || "",
image2Url: data.image2?.link || '' image2Id: prev.image2Id || data.image2Id || "",
}; },
}));
setFormData(mapped); if (data.image?.link && !previewImage) setPreviewImage(data.image.link);
setOriginalData(mapped); if (data.image2?.link && !previewImage2) setPreviewImage2(data.image2.link);
if (data.image?.link) setPreviewImage(data.image.link);
if (data.image2?.link) setPreviewImage2(data.image2.link);
} 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();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params?.id]); }, [params?.id]);
// 📤 Upload File Helper
const uploadFile = async (file: File): Promise<string | null> => {
try {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
return uploaded?.id || null;
} catch (error) {
console.error('Error uploading file:', error);
return null;
}
};
// 🔁 Reset Form
const handleResetForm = () => {
setFormData(originalData);
setPreviewImage(originalData.imageUrl || null);
setPreviewImage2(originalData.image2Url || null);
setFile(null);
setFile2(null);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit Handler // handler untuk submit
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
try { try {
setIsSubmitting(true); // update form global hanya saat submit
stateSurat.edit.form = { ...stateSurat.edit.form, ...formData };
// ✅ Access original state directly (not proxy) // upload file 1
const originalState = stateLayananDesa.suratKeterangan;
// Update form data properties individually
originalState.edit.form.name = formData.name;
originalState.edit.form.deskripsi = formData.deskripsi;
originalState.edit.form.imageId = formData.imageId;
originalState.edit.form.image2Id = formData.image2Id;
// Upload file 1 if exists
if (file) { if (file) {
const uploadedId = await uploadFile(file); const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
if (!uploadedId) { const uploaded = res.data?.data;
toast.error('Gagal upload gambar pertama'); if (!uploaded?.id) return toast.error('Gagal upload gambar');
return; stateSurat.edit.form.imageId = uploaded.id;
}
originalState.edit.form.imageId = uploadedId;
} }
// Upload file 2 if exists // upload file 2
if (file2) { if (file2) {
const uploadedId = await uploadFile(file2); const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
if (!uploadedId) { const uploaded = res.data?.data;
toast.error('Gagal upload gambar kedua'); if (!uploaded?.id) return toast.error('Gagal upload gambar');
return; stateSurat.edit.form.image2Id = uploaded.id;
}
originalState.edit.form.image2Id = uploadedId;
} }
// Submit update await stateSurat.edit.update();
await originalState.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');
} finally {
setIsSubmitting(false);
} }
}, [formData, file, file2, router]); }, [formData, file, file2, router, stateSurat.edit]);
// 📝 Form Field Handlers
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, name: e.target.value }));
};
const handleDeskripsiChange = (html: string) => {
setFormData(prev => ({ ...prev, deskripsi: html }));
};
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Back Button */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Surat Keterangan Edit Surat Keterangan
</Title> </Title>
</Group> </Group>
{/* Form */}
<Paper <Paper
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
@@ -284,66 +131,154 @@ function EditSuratKeterangan() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Nama Surat */} {/* Input nama */}
<TextInput <TextInput
label="Nama Surat Keterangan" label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan" placeholder="Masukkan nama surat keterangan"
value={formData.name} value={formData.name}
onChange={handleNameChange} onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required required
/> />
{/* Deskripsi */} {/* Input deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Konten Konten
</Text> </Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={handleDeskripsiChange} onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/> />
</Box> </Box>
{/* Gambar 1 */} {/* Upload Gambar 1 */}
<FileUploader <Box>
title="Gambar Konten Pelayanan" <Text fw="bold" fz="sm" mb={6}>
file={file} Gambar Konten Pelayanan
setFile={setFile} </Text>
preview={previewImage} <Dropzone
setPreview={setPreviewImage} onDrop={(files) => {
/> const selectedFile = files[0];
if (selectedFile) {
{/* Gambar 2 */} setFile(selectedFile);
<FileUploader setPreviewImage(URL.createObjectURL(selectedFile));
title="Gambar Alur Pelayanan Surat" }
file={file2} }}
setFile={setFile2} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
preview={previewImage2} maxSize={5 * 1024 ** 2}
setPreview={setPreviewImage2} accept={{ 'image/*': [] }}
/> radius="md"
p="xl"
{/* Action Buttons */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
onClick={handleResetForm}
disabled={isSubmitting}
> >
Batal <Group justify="center" gap="xl" mih={180}>
</Button> <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
src={previewImage}
alt="Preview Gambar 1"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
</Box>
)}
</Box>
{/* Upload Gambar 2 */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Alur Pelayanan Surat
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile2(selectedFile);
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
src={previewImage2}
alt="Preview Gambar 2"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/>
</Box>
)}
</Box>
<Group justify="right">
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
disabled={isSubmitting} size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
boxShadow: '0 4px 15px rgba(79,172,254,0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -352,4 +287,4 @@ function EditSuratKeterangan() {
); );
} }
export default EditSuratKeterangan; export default EditSuratKeterangan;

View File

@@ -10,7 +10,8 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -49,7 +50,7 @@ function DetailSuratKeterangan() {
const data = suratKeteranganState.findUnique.data; const data = suratKeteranganState.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -62,7 +63,7 @@ function DetailSuratKeterangan() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '70%' }} w={{ base: '100%', md: '60%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -75,21 +76,20 @@ function DetailSuratKeterangan() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Stack gap={"xs"}> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Nama Nama
</Text> </Text>
<Text fz="md" c="dimmed"> <Text fz="md" c="dimmed">
{data?.name || '-'} {data?.name || '-'}
</Text> </Text>
</Stack> </Box>
<Stack gap={"xs"}> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Deskripsi Deskripsi
</Text> </Text>
<Box pl={10}> <Text
<Text
fz="md" fz="md"
c="dimmed" c="dimmed"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -97,10 +97,9 @@ function DetailSuratKeterangan() {
}} }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>
</Stack>
<Stack gap={"xs"}> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Gambar Konten Pelayanan Gambar Konten Pelayanan
</Text> </Text>
@@ -119,7 +118,7 @@ function DetailSuratKeterangan() {
Tidak ada gambar Tidak ada gambar
</Text> </Text>
)} )}
</Stack> </Box>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
@@ -143,6 +142,7 @@ function DetailSuratKeterangan() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -156,7 +156,9 @@ function DetailSuratKeterangan() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -170,6 +172,7 @@ function DetailSuratKeterangan() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -5,17 +5,16 @@ 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 { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
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';
@@ -29,7 +28,6 @@ function CreateSuratKeterangan() {
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 [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
stateSurat.create.form = { stateSurat.create.form = {
@@ -48,7 +46,6 @@ function CreateSuratKeterangan() {
} }
try { try {
setIsSubmitting(true);
// Upload gambar utama // Upload gambar utama
const res1 = await ApiFetch.api.fileStorage.create.post({ const res1 = await ApiFetch.api.fileStorage.create.post({
file: previewImage.file, file: previewImage.file,
@@ -81,18 +78,18 @@ function CreateSuratKeterangan() {
} 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');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Surat Keterangan Tambah Surat Keterangan
</Title> </Title>
@@ -109,7 +106,7 @@ function CreateSuratKeterangan() {
<Stack gap="md"> <Stack gap="md">
{/* Nama Surat */} {/* Nama Surat */}
<TextInput <TextInput
value={stateSurat.create.form.name} defaultValue={stateSurat.create.form.name}
onChange={(val) => (stateSurat.create.form.name = val.target.value)} onChange={(val) => (stateSurat.create.form.name = val.target.value)}
label="Nama Surat Keterangan" label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan" placeholder="Masukkan nama surat keterangan"
@@ -146,7 +143,7 @@ function CreateSuratKeterangan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -167,7 +164,7 @@ function CreateSuratKeterangan() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage.preview} src={previewImage.preview}
alt="Preview Gambar Utama" alt="Preview Gambar Utama"
@@ -175,23 +172,6 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -213,7 +193,7 @@ function CreateSuratKeterangan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -234,7 +214,7 @@ function CreateSuratKeterangan() {
</Dropzone> </Dropzone>
{previewImage2 ? ( {previewImage2 ? (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage2.preview} src={previewImage2.preview}
alt="Preview Gambar Tambahan" alt="Preview Gambar Tambahan"
@@ -242,23 +222,6 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage2(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
) : ( ) : (
<Text size="sm" c="dimmed" mt="sm" ta="center"> <Text size="sm" c="dimmed" mt="sm" ta="center">
@@ -269,17 +232,6 @@ function CreateSuratKeterangan() {
{/* Tombol Simpan */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -290,7 +242,7 @@ function CreateSuratKeterangan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -18,6 +18,7 @@ import {
TableTr, TableTr,
Text, Text,
Title, Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -25,10 +26,9 @@ 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 stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function SuratKeterangan() { function SuratKeterangan() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
@@ -46,7 +46,6 @@ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { const {
data, data,
@@ -57,80 +56,74 @@ function ListSuratKeterangan({ search }: { search: string }) {
} = suratKeteranganState.findMany; } = suratKeteranganState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!data) return []; if (!data) return [];
const keyword = debouncedSearch.toLowerCase(); const keyword = search.toLowerCase();
return data.filter( return data.filter(item =>
(item) => item.name?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword) || item.deskripsi?.toLowerCase().includes(keyword)
item.deskripsi?.toLowerCase().includes(keyword)
); );
}, [data, debouncedSearch]); }, [data, search]);
// Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={{ base: 'sm', md: 'md' }}> <Stack py={10}>
<Skeleton height={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={{ base: 'sm', md: 'md' }}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}> <Group justify="space-between" mb="md">
<Title order={4} lh={1.2}> <Title order={4}>List Surat Keterangan</Title>
List Surat Keterangan <Tooltip label="Tambah Surat Keterangan" withArrow>
</Title> <Button
<Button leftSection={<IconPlus size={18} />}
leftSection={<IconPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() =>
onClick={() => router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create')
router.push('/admin/desa/layanan/pelayanan_surat_keterangan/create') }
} >
> Tambah Baru
Tambah Baru </Button>
</Button> </Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh fz="sm" fw={600} ta="left"> <TableTh style={{ width: '30%' }}>Nama</TableTh>
Nama <TableTh style={{ width: '45%' }}>Deskripsi</TableTh>
</TableTh> <TableTh style={{ width: '15%' }}>Aksi</TableTh>
<TableTh fz="sm" fw={600} ta="left">
Deskripsi
</TableTh>
<TableTh fz="sm" fw={600} ta="left">
Aksi
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd style={{ width: '30%' }}>
<Text fz="md" fw={500} lh={1.5} truncate="end"> <Box w={200}>
{item.name} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.name}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '45%' }}>
<Text <Box w={200}>
fz="sm" <Text truncate="end" lineClamp={1} fz="sm" c="dimmed"
lh={1.5} dangerouslySetInnerHTML={{ __html: item.deskripsi }}
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }} style={{wordBreak: "break-word", whiteSpace: "normal"}}
style={{ wordBreak: 'break-word' }} />
/> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd style={{ width: '15%' }}>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
@@ -138,9 +131,7 @@ function ListSuratKeterangan({ search }: { search: string }) {
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => onClick={() =>
router.push( router.push(`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`)
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
)
} }
> >
Detail Detail
@@ -151,10 +142,8 @@ function ListSuratKeterangan({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
<Center py="xl"> <Center py={20}>
<Text c="dimmed" fz="sm" ta="center"> <Text color="dimmed">Tidak ada data surat keterangan yang cocok</Text>
Tidak ada data surat keterangan yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -162,67 +151,7 @@ function ListSuratKeterangan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
<Stack gap="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="md">
<Stack gap={'xs'}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Deskripsi
</Text>
<Box pl={8}>
<Text
fz="sm"
fw={500}
lh={1.4}
dangerouslySetInnerHTML={{ __html: item.deskripsi || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Box>
</Box>
<Box>
<Button
fullWidth
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_surat_keterangan/${item.id}`
)
}
>
Detail
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" ta="center">
Tidak ada data surat keterangan yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -241,4 +170,4 @@ function ListSuratKeterangan({ search }: { search: string }) {
); );
} }
export default SuratKeterangan; export default SuratKeterangan;

View File

@@ -6,11 +6,11 @@ import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
Stack, Stack,
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';
@@ -22,7 +22,6 @@ 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 [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -30,12 +29,6 @@ function EditPelayananTelunjukSakti() {
link: '', link: '',
}); });
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
link: '',
});
// Load data awal hanya sekali (pas ada id) // Load data awal hanya sekali (pas ada id)
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -50,11 +43,6 @@ function EditPelayananTelunjukSakti() {
deskripsi: data.deskripsi ?? '', deskripsi: data.deskripsi ?? '',
link: data.link ?? '', link: data.link ?? '',
}); });
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
link: data.link ?? '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading pelayanan telunjuk sakti:', error); console.error('Error loading pelayanan telunjuk sakti:', error);
@@ -73,19 +61,9 @@ function EditPelayananTelunjukSakti() {
[] []
); );
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
// Submit: update global state hanya saat simpan // Submit: update global state hanya saat simpan
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
stateTelunjukDesa.edit.form = { stateTelunjukDesa.edit.form = {
...stateTelunjukDesa.edit.form, ...stateTelunjukDesa.edit.form,
...formData, ...formData,
@@ -96,18 +74,18 @@ function EditPelayananTelunjukSakti() {
} 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');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */} {/* Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Pelayanan Telunjuk Sakti Desa Edit Pelayanan Telunjuk Sakti Desa
</Title> </Title>
@@ -150,17 +128,6 @@ function EditPelayananTelunjukSakti() {
{/* Tombol Simpan */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -171,7 +138,7 @@ function EditPelayananTelunjukSakti() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -9,7 +9,8 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -50,7 +51,7 @@ function DetailPelayananTelunjukSakti() {
const data = telunjukSaktiState.findUnique.data; const data = telunjukSaktiState.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
{/* Tombol Kembali */} {/* Tombol Kembali */}
<Button <Button
variant="subtle" variant="subtle"
@@ -63,7 +64,7 @@ function DetailPelayananTelunjukSakti() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '70%' }} w={{ base: '100%', md: '60%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -129,6 +130,7 @@ function DetailPelayananTelunjukSakti() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Layanan" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -142,7 +144,9 @@ function DetailPelayananTelunjukSakti() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Layanan" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -156,6 +160,7 @@ function DetailPelayananTelunjukSakti() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -6,22 +6,20 @@ import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
Stack, Stack,
TextInput, TextInput,
Title Title,
Tooltip
} from '@mantine/core'; } 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 { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreatePelayananTelunjukDesa() { function CreatePelayananTelunjukDesa() {
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa); const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
stateTelunjukDesa.create.form = { stateTelunjukDesa.create.form = {
@@ -33,7 +31,6 @@ function CreatePelayananTelunjukDesa() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
await stateTelunjukDesa.create.create(); await stateTelunjukDesa.create.create();
resetForm(); resetForm();
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan'); toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
@@ -41,18 +38,18 @@ function CreatePelayananTelunjukDesa() {
} catch (error) { } catch (error) {
console.error('Error create pelayanan telunjuk sakti:', error); console.error('Error create pelayanan telunjuk sakti:', error);
toast.error('Terjadi kesalahan saat menambahkan data'); toast.error('Terjadi kesalahan saat menambahkan data');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Pelayanan Telunjuk Sakti Desa Tambah Pelayanan Telunjuk Sakti Desa
</Title> </Title>
@@ -70,7 +67,7 @@ function CreatePelayananTelunjukDesa() {
<Stack gap="md"> <Stack gap="md">
{/* Nama */} {/* Nama */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.name} defaultValue={stateTelunjukDesa.create.form.name}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.name = val.target.value; stateTelunjukDesa.create.form.name = val.target.value;
}} }}
@@ -81,7 +78,7 @@ function CreatePelayananTelunjukDesa() {
{/* Deskripsi */} {/* Deskripsi */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.deskripsi} defaultValue={stateTelunjukDesa.create.form.deskripsi}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.deskripsi = val.target.value; stateTelunjukDesa.create.form.deskripsi = val.target.value;
}} }}
@@ -92,7 +89,7 @@ function CreatePelayananTelunjukDesa() {
{/* Link */} {/* Link */}
<TextInput <TextInput
value={stateTelunjukDesa.create.form.link} defaultValue={stateTelunjukDesa.create.form.link}
onChange={(val) => { onChange={(val) => {
stateTelunjukDesa.create.form.link = val.target.value; stateTelunjukDesa.create.form.link = val.target.value;
}} }}
@@ -103,17 +100,6 @@ function CreatePelayananTelunjukDesa() {
{/* Tombol Simpan */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -124,7 +110,7 @@ function CreatePelayananTelunjukDesa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,5 +1,161 @@
// /* 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 { import {
@@ -19,6 +175,7 @@ import {
TableTr, TableTr,
Text, Text,
Title, Title,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -26,10 +183,9 @@ 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 stateLayananDesa from '../../../_state/desa/layananDesa'; import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useDebouncedValue } from '@mantine/hooks';
function PelayananTelunjukSakti() { function PelayananTelunjukSakti() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState("");
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
@@ -47,57 +203,48 @@ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = telunjukSaktiState.findMany; const { data, page, totalPages, loading, load } = telunjukSaktiState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
const filteredData = data || []; const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={{ base: 'sm', md: 'md' }}> <Stack py={10}>
<Skeleton height={400} radius="md" /> <Skeleton height={400} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py={{ base: 'sm', md: 'md' }}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4} lh={1.2}> <Title order={4}>Daftar Pelayanan Telunjuk Sakti</Title>
Daftar Pelayanan Telunjuk Sakti <Tooltip label="Tambah Layanan" withArrow>
</Title> <Button
<Button leftSection={<IconPlus size={18} />}
leftSection={<IconPlus size={18} />} color="blue"
color="blue" variant="light"
variant="light" onClick={() =>
onClick={() => router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create')
router.push('/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create') }
} >
> Tambah Baru
Tambah Baru </Button>
</Button> </Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
{/* Desktop Table */} <Table highlightOnHover style={{ minWidth: '700px' }}>
<Box visibleFrom="md">
<Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh fz="sm" fw={600} ta="left" c="gray.8" w="30%"> <TableTh style={{ width: '30%' }}>Nama</TableTh>
Nama <TableTh style={{ width: '40%' }}>Link</TableTh>
</TableTh> <TableTh style={{ width: '30%' }}>Detail</TableTh>
<TableTh fz="sm" fw={600} ta="left" c="gray.8" w="40%">
Link
</TableTh>
<TableTh fz="sm" fw={600} ta="left" c="gray.8" w="30%">
Detail
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -105,19 +252,18 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="sm" fw={500} lh={1.5} truncate="end"> <Box w={200}>
{item.name} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.name}
</Text></Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<a href={item.link} target="_blank" rel="noopener noreferrer"> <Box w={200}>
<Text <a href={item.link} target="_blank" rel="noopener noreferrer">
fz="sm" <Text lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} style={{wordBreak: "break-word", whiteSpace: "normal"}} truncate="end" fz={"sm"} />
lh={1.5} </a>
truncate="end" </Box>
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</a>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
@@ -130,9 +276,7 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
} }
> >
<IconDeviceImacCog size={20} /> <IconDeviceImacCog size={20} />
<Text ml="xs" fz="sm" fw={500}> <Text ml={5}>Detail</Text>
Detail
</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -140,8 +284,8 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
<Center py="lg"> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.5}> <Text color="dimmed">
Tidak ada data layanan yang cocok Tidak ada data layanan yang cocok
</Text> </Text>
</Center> </Center>
@@ -151,68 +295,17 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
<Stack gap="md">
{filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="sm">
<Box mb="xs">
<Text fz='sm' fw={600} lh={1.4} c="gray.8">
Nama
</Text>
<Text fz="sm" fw={500} lh={1.5}>
{item.name}
</Text>
</Box>
<Box mb="xs">
<Text fz='sm' fw={600} lh={1.4} c="gray.8">
Link
</Text>
<Text fz="sm" fw={500} lh={1.5} component="a" href={item.link} target="_blank" rel="noopener noreferrer">
{item.deskripsi}
</Text>
</Box>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(
`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml="xs" fz="sm" fw={500}>
Detail
</Text>
</Button>
</Paper>
))}
</Stack>
) : (
<Center py="lg">
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada data layanan yang cocok
</Text>
</Center>
)}
</Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => {
load(newPage, 10, search); load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }}
}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md"
color="blue" color="blue"
radius="md" radius="md"
/> />
@@ -221,4 +314,5 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
); );
} }
export default PelayananTelunjukSakti; export default PelayananTelunjukSakti;

View File

@@ -5,17 +5,16 @@ 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 { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
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';
@@ -31,15 +30,6 @@ function EditPenghargaan() {
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 [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
juara: "",
deskripsi: "",
imageId: "",
imageUrl: "",
});
// Lokal formData // Lokal formData
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -57,25 +47,17 @@ function EditPenghargaan() {
try { try {
const data = await statePenghargaan.edit.load(id); const data = await statePenghargaan.edit.load(id);
if (data) { if (data) {
const newForm = { setFormData({
name: data.name || "", name: data.name || '',
juara: data.juara || "", juara: data.juara || '',
deskripsi: data.deskripsi || "", deskripsi: data.deskripsi || '',
imageId: data.imageId || "", imageId: data.imageId || '',
};
setFormData(newForm);
// simpan juga versi original
const imageUrl = data.image?.link || "";
setOriginalData({
...newForm,
imageUrl: imageUrl,
}); });
setPreviewImage(imageUrl || null); if (data?.image?.link) {
setPreviewImage(data.image.link);
}
} }
} catch (error) { } catch (error) {
console.error('Error loading penghargaan:', error); console.error('Error loading penghargaan:', error);
@@ -86,59 +68,45 @@ function EditPenghargaan() {
loadPenghargaan(); loadPenghargaan();
}, [params?.id]); }, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
juara: originalData.juara,
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
// Submit // Submit
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Sync ke global state saat submit // Sync ke global state saat submit
let imageId = formData.imageId; statePenghargaan.edit.form = {
...statePenghargaan.edit.form,
...formData,
};
// Upload file baru (kalau ada)
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');
} }
imageId = uploaded.id;
statePenghargaan.edit.form.imageId = uploaded.id;
} }
// Update global state form (baru di sini)
statePenghargaan.edit.form = {
...statePenghargaan.edit.form,
name: formData.name,
juara: formData.juara,
deskripsi: formData.deskripsi,
imageId,
}
await statePenghargaan.edit.update(); await statePenghargaan.edit.update();
toast.success('Penghargaan berhasil diperbarui!'); toast.success('Penghargaan berhasil diperbarui!');
router.push('/admin/desa/penghargaan'); router.push('/admin/desa/penghargaan');
} catch (error) { } catch (error) {
console.error('Error updating penghargaan:', error); console.error('Error updating penghargaan:', error);
toast.error('Terjadi kesalahan saat memperbarui penghargaan'); toast.error('Terjadi kesalahan saat memperbarui penghargaan');
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Tombol Back + Title */} {/* Tombol Back + Title */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Penghargaan Edit Penghargaan
</Title> </Title>
@@ -187,7 +155,7 @@ function EditPenghargaan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -206,47 +174,25 @@ function EditPenghargaan() {
Seret gambar atau klik untuk memilih file Seret gambar atau klik untuk memilih file
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp Maksimal 5MB, format gambar wajib
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box pos="relative" mt="sm" style={{ display: 'flex', justifyContent: 'center' }}> <Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box> <Image
<Image src={previewImage}
src={previewImage.startsWith('http') ? previewImage : `${window.location.origin}${previewImage}`} alt="Preview Gambar"
alt="Preview Gambar" radius="md"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
</Box>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{ style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}} }}
> loading="lazy"
<IconX size={14} /> />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -266,17 +212,6 @@ function EditPenghargaan() {
{/* Tombol Simpan */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -287,7 +222,7 @@ function EditPenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,5 +1,9 @@
'use client' 'use client'
import colors from '@/con/colors'; import React, { useState } from 'react';
import penghargaanState from '../../../_state/desa/penghargaan';
import { useProxy } from 'valtio/utils';
import { useParams, useRouter } from 'next/navigation';
import { useShallowEffect } from '@mantine/hooks';
import { import {
Box, Box,
Button, Button,
@@ -8,15 +12,12 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import colors from '@/con/colors';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import penghargaanState from '../../../_state/desa/penghargaan';
function DetailPenghargaan() { function DetailPenghargaan() {
const statePenghargaan = useProxy(penghargaanState); const statePenghargaan = useProxy(penghargaanState);
@@ -49,7 +50,7 @@ function DetailPenghargaan() {
const data = statePenghargaan.findUnique.data; const data = statePenghargaan.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -126,30 +127,34 @@ function DetailPenghargaan() {
</Box> </Box>
<Group gap="sm" mt={10}> <Group gap="sm" mt={10}>
<Button <Tooltip label="Hapus Penghargaan" withArrow position="top">
color="red" <Button
onClick={() => { color="red"
setSelectedId(data.id); onClick={() => {
setModalHapus(true); setSelectedId(data.id);
}} setModalHapus(true);
variant="light" }}
radius="md" variant="light"
size="md" radius="md"
> size="md"
<IconTrash size={20} /> >
</Button> <IconTrash size={20} />
</Button>
</Tooltip>
<Button <Tooltip label="Edit Penghargaan" withArrow position="top">
color="green" <Button
onClick={() => color="green"
router.push(`/admin/desa/penghargaan/${data.id}/edit`) onClick={() =>
} router.push(`/admin/desa/penghargaan/${data.id}/edit`)
variant="light" }
radius="md" variant="light"
size="md" radius="md"
> size="md"
<IconEdit size={20} /> >
</Button> <IconEdit size={20} />
</Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -2,17 +2,16 @@
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
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';
@@ -28,7 +27,6 @@ function CreatePenghargaan() {
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 [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
statePenghargaan.create.form = { statePenghargaan.create.form = {
@@ -42,43 +40,37 @@ function CreatePenghargaan() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { if (!file) {
setIsSubmitting(true); return toast.warn('Silakan pilih file gambar terlebih dahulu');
if (!file) {
return toast.warn('Silakan 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 mengunggah gambar, silakan coba lagi');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
} catch (error) {
console.error('Error creating penghargaan:', error);
toast.error('Terjadi kesalahan saat menambahkan penghargaan');
} finally {
setIsSubmitting(false);
} }
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
statePenghargaan.create.form.imageId = uploaded.id;
await statePenghargaan.create.create();
resetForm();
router.push('/admin/desa/penghargaan');
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Penghargaan Tambah Penghargaan
</Title> </Title>
@@ -95,7 +87,7 @@ function CreatePenghargaan() {
> >
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
value={statePenghargaan.create.form.name} defaultValue={statePenghargaan.create.form.name}
onChange={(val) => (statePenghargaan.create.form.name = val.target.value)} onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
label="Nama Penghargaan" label="Nama Penghargaan"
placeholder="Masukkan nama penghargaan" placeholder="Masukkan nama penghargaan"
@@ -103,7 +95,7 @@ function CreatePenghargaan() {
/> />
<TextInput <TextInput
value={statePenghargaan.create.form.juara} defaultValue={statePenghargaan.create.form.juara}
onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)} onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
label="Juara" label="Juara"
placeholder="Masukkan juara" placeholder="Masukkan juara"
@@ -133,7 +125,7 @@ function CreatePenghargaan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} accept={{ 'image/*': [] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -154,7 +146,7 @@ function CreatePenghargaan() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}> <Box mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar"
@@ -162,41 +154,12 @@ function CreatePenghargaan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
{/* Button Submit */} {/* Button Submit */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -207,7 +170,7 @@ function CreatePenghargaan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -18,14 +18,14 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; 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 { useDebouncedValue } from '@mantine/hooks';
function Penghargaan() { function Penghargaan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -46,48 +46,47 @@ 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 [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany; const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(page, 10, search);
}, [page, debouncedSearch]); }, [page, search]);
const filteredData = data || []; const filteredData = data || []
// Loading state // Loading state
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py="md"> <Stack py={10}>
<Skeleton h={600} radius="md" /> <Skeleton height={600} radius="md" />
</Stack> </Stack>
); );
} }
return ( return (
<Box py="md"> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="lg"> <Group justify="space-between" mb="md">
<Title order={4}>List Penghargaan</Title> <Title order={4}>List Penghargaan</Title>
<Button <Tooltip label="Tambah Penghargaan" withArrow>
leftSection={<IconPlus size={18} />} <Button
color="blue" leftSection={<IconPlus size={18} />}
variant="light" color="blue"
onClick={() => router.push('/admin/desa/penghargaan/create')} variant="light"
> onClick={() => router.push('/admin/desa/penghargaan/create')}
Tambah Baru >
</Button> Tambah Baru
</Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}>
{/* Desktop Table */}
<Box visibleFrom="md">
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w="35%">Nama</TableTh> <TableTh style={{ width: '35%' }}>Nama</TableTh>
<TableTh w="35%">Deskripsi</TableTh> <TableTh style={{ width: '35%' }}>Deskripsi</TableTh>
<TableTh w="30%">Aksi</TableTh> <TableTh style={{ width: '30%' }}>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
@@ -95,27 +94,31 @@ function ListPenghargaan({ search }: { search: string }) {
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="md" fw={500} lh={1.45} truncate="end" lineClamp={1}> <Box w={200}>
{item.name} <Text fw={500} truncate="end" lineClamp={1}>
</Text> {item.name}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text <Box w={200}>
fz="sm" <Text
lh={1.45} truncate="end"
c="dimmed" lineClamp={1}
dangerouslySetInnerHTML={{ __html: item.deskripsi }} fz="sm"
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }} c="dimmed"
lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/> style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
variant="light" variant="light"
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => onClick={() =>
router.push(`/admin/desa/penghargaan/${item.id}`) router.push(`/admin/desa/penghargaan/${item.id}`)
} }
@@ -127,9 +130,9 @@ function ListPenghargaan({ search }: { search: string }) {
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={4}>
<Center py="xl"> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text color="dimmed">
Tidak ada data penghargaan yang cocok Tidak ada data penghargaan yang cocok
</Text> </Text>
</Center> </Center>
@@ -139,54 +142,7 @@ function ListPenghargaan({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Box>
<Text fz="xs" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.45}>
{item.name}
</Text>
</Box>
<Box mt="xs">
<Text fz="xs" fw={600} lh={1.4}>
Deskripsi
</Text>
<Text lineClamp={3} fz="sm" fw={500} lh={1.45} c="dimmed">
<span dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Text>
</Box>
<Group mt="md">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/desa/penghargaan/${item.id}`)
}
>
Detail
</Button>
</Group>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data penghargaan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -195,7 +151,7 @@ function ListPenghargaan({ search }: { search: string }) {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
total={totalPages} total={totalPages}
mt="lg" mt="md"
mb="md" mb="md"
color="blue" color="blue"
radius="md" radius="md"
@@ -205,4 +161,4 @@ function ListPenghargaan({ search }: { search: string }) {
); );
} }
export default Penghargaan; export default Penghargaan;

View File

@@ -1,10 +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 { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
import { IconCategory, IconListDetails } 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';
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()
@@ -14,13 +14,15 @@ 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} /> 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} /> icon: <IconCategory size={18} stroke={1.8} />,
tooltip: "Kelola kategori pengumuman"
}, },
]; ];
@@ -54,76 +56,36 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
keepMounted={false} keepMounted={false}
> >
{/* ✅ Scroll horizontal wrapper */} {/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}> <ScrollArea type="auto" offsetScrollbars>
<ScrollArea type="auto" offsetScrollbars> <TabsList
<TabsList p="sm"
p="sm" style={{
style={{ background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
background: "linear-gradient(135deg, #e7ebf7, #f9faff)", borderRadius: "1rem",
borderRadius: "1rem", boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)", display: "flex",
display: "flex", flexWrap: "nowrap",
flexWrap: "nowrap", gap: "0.5rem",
gap: "0.5rem", paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi }}
}} >
> {tabs.map((tab, i) => (
{tabs.map((tab, i) => ( <Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
fontWeight: 600, fontWeight: 600,
fontSize: "0.9rem", fontSize: "0.9rem",
transition: "all 0.2s ease", transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}} }}
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
))} </Tooltip>
</TabsList> ))}
</ScrollArea> </TabsList>
</Box> </ScrollArea>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<TabsPanel <TabsPanel

View File

@@ -11,7 +11,7 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Loader 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';
@@ -23,9 +23,8 @@ 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 [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ name: '' }); const [formData, setFormData] = useState({ name: '' });
const [originalData, setOriginalData] = useState({ name: '' });
// Load data awal sekali aja // Load data awal sekali aja
useEffect(() => { useEffect(() => {
@@ -37,7 +36,6 @@ function EditKategoriPengumuman() {
const data = await editState.update.load(id); const data = await editState.update.load(id);
if (data) { if (data) {
setFormData({ name: data.name || '' }); setFormData({ name: data.name || '' });
setOriginalData({ name: data.name || '' });
} }
} catch (error) { } catch (error) {
console.error('Error loading kategori Pengumuman:', error); console.error('Error loading kategori Pengumuman:', error);
@@ -57,7 +55,6 @@ function EditKategoriPengumuman() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Update global state hanya di sini // Update global state hanya di sini
editState.update.form = { editState.update.form = {
...editState.update.form, ...editState.update.form,
@@ -70,23 +67,14 @@ function EditKategoriPengumuman() {
} catch (error) { } catch (error) {
console.error('Error updating kategori Pengumuman:', error); console.error('Error updating kategori Pengumuman:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Pengumuman'); toast.error('Terjadi kesalahan saat memperbarui kategori Pengumuman');
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -95,6 +83,7 @@ function EditKategoriPengumuman() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Kategori Pengumuman Edit Kategori Pengumuman
</Title> </Title>
@@ -119,17 +108,6 @@ function EditKategoriPengumuman() {
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -140,7 +118,7 @@ function EditKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -9,18 +9,15 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Loader Tooltip
} from '@mantine/core'; } 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 { useState } from 'react';
import { toast } from 'react-toastify';
function CreateKategoriPengumuman() { function CreateKategoriPengumuman() {
const createState = useProxy(stateDesaPengumuman.category); const createState = useProxy(stateDesaPengumuman.category);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
@@ -29,22 +26,16 @@ function CreateKategoriPengumuman() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { await createState.create.create();
await createState.create.create(); resetForm();
resetForm(); router.push('/admin/desa/pengumuman/kategori-pengumuman');
router.push('/admin/desa/pengumuman/kategori-pengumuman');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan kategori pengumuman');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -53,6 +44,7 @@ function CreateKategoriPengumuman() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Kategori Pengumuman Tambah Kategori Pengumuman
</Title> </Title>
@@ -71,23 +63,12 @@ function CreateKategoriPengumuman() {
<TextInput <TextInput
label="Nama Kategori Pengumuman" label="Nama Kategori Pengumuman"
placeholder="Masukkan nama kategori pengumuman" placeholder="Masukkan nama kategori pengumuman"
value={createState.create.form.name || ''} defaultValue={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)} onChange={(e) => (createState.create.form.name = e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -98,7 +79,7 @@ function CreateKategoriPengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -2,30 +2,17 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Box, Button, Center, Paper, Skeleton, Stack,
Button, Table, TableTbody, TableTd, TableTh, TableThead, TableTr,
Center, Text, Title, Tooltip, Pagination
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit, IconPlus, 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 { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '../../../_state/desa/pengumuman'; import stateDesaPengumuman from '../../../_state/desa/pengumuman';
import { useDebouncedValue } from '@mantine/hooks';
function KategoriPengumuman() { function KategoriPengumuman() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -44,145 +31,105 @@ function KategoriPengumuman() {
} }
function ListKategoriPengumuman({ search }: { search: string }) { function ListKategoriPengumuman({ search }: { search: string }) {
const listDataState = useProxy(stateDesaPengumuman.category); const listDataState = useProxy(stateDesaPengumuman.category)
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 [debouncedSearch] = useDebouncedValue(search, 500);
const { data, page, totalPages, loading, load } = listDataState.findMany; const { data, page, totalPages, loading, load } = listDataState.findMany;
useEffect(() => { useEffect(() => {
load(page, 10, debouncedSearch); load(1, 10, search)
}, [page, debouncedSearch]); }, [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); load(page, 10, search)
} }
}; }
const filteredData = data || []; const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
<Stack py={{ base: 'md', md: 'lg' }}> <Stack py={10}>
<Skeleton height={500} radius="md" /> <Skeleton height={500} radius="md" />
</Stack> </Stack>
); )
} }
return ( return (
<Box py={{ base: 'md', md: 'lg' }}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack gap={'lg'}> <Stack>
<Box <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
visibleFrom="md" <Title order={4}>List Kategori Pengumuman</Title>
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} <Tooltip label="Tambah Kategori Pengumuman" withArrow>
>
<Title order={4} lh={1.1}>
List Kategori Pengumuman
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/pengumuman/kategori-pengumuman/create')
}
>
Tambah Baru
</Button>
</Box>
<Box hiddenFrom="md">
<Stack gap="xs">
<Title order={2} size="md" lh={1.1} ta="left">
List Kategori Pengumuman
</Title>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')}
router.push('/admin/desa/pengumuman/kategori-pengumuman/create')
}
fullWidth
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Stack> </Tooltip>
</Box> </Box>
<Box visibleFrom="md"> <Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover striped withRowBorders> <Table highlightOnHover striped withRowBorders style={{ minWidth: '700px' }}>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh w="60%"> <TableTh style={{ width: '10%' }}>No</TableTh>
<Text fz="sm" fw={600} lh={1.4}> <TableTh style={{ width: '60%' }}>Nama</TableTh>
Nama <TableTh style={{ width: '15%' }}>Edit</TableTh>
</Text> <TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTh>
<TableTh w="15%">
<Text fz="sm" fw={600} lh={1.4}>
Edit
</Text>
</TableTh>
<TableTh w="15%">
<Text fz="sm" fw={600} lh={1.4}>
Hapus
</Text>
</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item, index) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd> <TableTd>
<Text fz="md" fw={500} lh={1.5} truncate> <Text fz="sm">{(page - 1) * 10 + index + 1}</Text>
{item.name}
</Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Text truncate lineClamp={1}>{item.name}</Text>
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`
)
}
size="compact-sm"
>
<IconEdit size={16} />
</Button>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button <Tooltip label="Edit Kategori Pengumuman" withArrow>
variant="light" <Button
color="red" variant='light'
disabled={listDataState.delete.loading} color='green'
onClick={() => { onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}
setSelectedId(item.id); >
setModalHapus(true); <IconEdit size={20} />
}} </Button>
size="compact-sm" </Tooltip>
> </TableTd>
<IconTrash size={16} /> <TableTd>
</Button> <Tooltip label="Hapus Kategori Pengumuman" withArrow>
<Button
variant='light'
color='red'
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id)
setModalHapus(true)
}}>
<IconTrash size={20} />
</Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={24}> <Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}> <Text c="dimmed">Tidak ada data kategori pengumuman yang cocok</Text>
Tidak ada data kategori pengumuman yang cocok
</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -190,71 +137,6 @@ function ListKategoriPengumuman({ search }: { search: string }) {
</TableTbody> </TableTbody>
</Table> </Table>
</Box> </Box>
<Stack hiddenFrom="md" gap="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper
key={item.id}
withBorder
p="md"
radius="md"
bg={colors['white-1']}
>
<Stack gap="xs">
<Box>
<Text fz="xs" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.name}
</Text>
</Box>
<Box
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-end',
}}
>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`
)
}
size="compact-xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-xs"
>
<IconTrash size={14} />
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Paper withBorder p="xl" radius="md" bg={colors['white-1']}>
<Center>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori pengumuman yang cocok
</Text>
</Center>
</Paper>
)}
</Stack>
</Stack> </Stack>
</Paper> </Paper>
@@ -275,7 +157,7 @@ function ListKategoriPengumuman({ search }: { search: string }) {
text='Apakah anda yakin ingin menghapus kategori Pengumuman ini?' text='Apakah anda yakin ingin menghapus kategori Pengumuman ini?'
/> />
</Box> </Box>
); )
} }
export default KategoriPengumuman; export default KategoriPengumuman;

View File

@@ -1,29 +1,7 @@
'use client'
import React from 'react'; import React from 'react';
import LayoutTabs from './_com/layoutTabs'; import LayoutTabs from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/pengumuman/semua → panjang 5 → list
// - /darmasaba/desa/pengumuman/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/pengumuman/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return ( return (
<LayoutTabs> <LayoutTabs>
{children} {children}

View File

@@ -14,7 +14,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader 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";
@@ -34,15 +34,6 @@ function EditPengumuman() {
content: "", content: "",
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
categoryPengumumanId: "",
content: "",
});
// Load kategori & pengumuman by id saat pertama kali // Load kategori & pengumuman by id saat pertama kali
useEffect(() => { useEffect(() => {
editState.category.findMany.load(); editState.category.findMany.load();
@@ -60,12 +51,6 @@ function EditPengumuman() {
categoryPengumumanId: data.categoryPengumumanId || "", categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "", content: data.content || "",
}); });
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
categoryPengumumanId: data.categoryPengumumanId || "",
content: data.content || "",
});
} }
} catch (error) { } catch (error) {
console.error("Error loading pengumuman:", error); console.error("Error loading pengumuman:", error);
@@ -82,7 +67,6 @@ function EditPengumuman() {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// update global state hanya sekali pas submit // update global state hanya sekali pas submit
editState.pengumuman.edit.form = { editState.pengumuman.edit.form = {
...editState.pengumuman.edit.form, ...editState.pengumuman.edit.form,
@@ -95,32 +79,22 @@ function EditPengumuman() {
} catch (error) { } catch (error) {
console.error("Error updating pengumuman:", error); console.error("Error updating pengumuman:", error);
toast.error("Terjadi kesalahan saat memperbarui pengumuman"); toast.error("Terjadi kesalahan saat memperbarui pengumuman");
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
categoryPengumumanId: originalData.categoryPengumumanId,
content: originalData.content,
});
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: "sm", md: "lg" }} py="md">
<Group mb="md"> <Group mb="md">
<Button <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
variant="subtle" <Button
onClick={() => router.back()} variant="subtle"
p="xs" onClick={() => router.back()}
radius="md" p="xs"
> radius="md"
<IconArrowBack color={colors["blue-button"]} size={24} /> >
</Button> <IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Pengumuman Edit Pengumuman
</Title> </Title>
@@ -181,29 +155,17 @@ function EditPengumuman() {
</Box> </Box>
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: '#fff', color: "#fff",
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,7 +1,5 @@
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Box,
@@ -10,13 +8,16 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text Text,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
export default function DetailPengumuman() { export default function DetailPengumuman() {
const pengumumanState = useProxy(stateDesaPengumuman); const pengumumanState = useProxy(stateDesaPengumuman);
@@ -49,7 +50,7 @@ export default function DetailPengumuman() {
const data = pengumumanState.pengumuman.findUnique.data; const data = pengumumanState.pengumuman.findUnique.data;
return ( return (
<Box px={{ base: 0, md: 'xs' }} py="xs"> <Box py={10}>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -61,7 +62,7 @@ export default function DetailPengumuman() {
<Paper <Paper
withBorder withBorder
w={{ base: '100%', md: '70%' }} w={{ base: '100%', md: '60%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -74,6 +75,14 @@ export default function DetailPengumuman() {
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm"> <Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">
Kategori
</Text>
<Text fz="md" c="dimmed">
{data?.CategoryPengumuman?.name || '-'}
</Text>
</Box>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
@@ -84,15 +93,6 @@ export default function DetailPengumuman() {
</Text> </Text>
</Box> </Box>
<Box>
<Text fz="lg" fw="bold">
Kategori
</Text>
<Text fz="md" c="dimmed">
{data?.CategoryPengumuman?.name || '-'}
</Text>
</Box>
<Box> <Box>
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Deskripsi Deskripsi
@@ -117,6 +117,7 @@ export default function DetailPengumuman() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Pengumuman" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -129,7 +130,9 @@ export default function DetailPengumuman() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Pengumuman" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -143,6 +146,7 @@ export default function DetailPengumuman() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -13,36 +13,25 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Tooltip,
} from '@mantine/core'; } 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 { useState } from 'react';
import { toast } from 'react-toastify';
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();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => { useShallowEffect(() => {
pengumumanState.category.findMany.load(); pengumumanState.category.findMany.load();
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
try { await pengumumanState.pengumuman.create.create();
setIsSubmitting(true); resetForm();
await pengumumanState.pengumuman.create.create(); router.push('/admin/desa/pengumuman/list-pengumuman');
resetForm();
router.push('/admin/desa/pengumuman/list-pengumuman');
} catch (error) {
console.error('Error creating pengumuman:', error);
toast.error('Terjadi kesalahan saat membuat pengumuman');
} finally {
setIsSubmitting(false);
}
}; };
const resetForm = () => { const resetForm = () => {
@@ -55,12 +44,14 @@ function CreatePengumuman() {
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<IconArrowBack color={colors['blue-button']} size={24} /> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
</Button> <IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Pengumuman Tambah Pengumuman
</Title> </Title>
@@ -77,7 +68,7 @@ function CreatePengumuman() {
<Stack gap="md"> <Stack gap="md">
{/* Judul */} {/* Judul */}
<TextInput <TextInput
value={pengumumanState.pengumuman.create.form.judul} defaultValue={pengumumanState.pengumuman.create.form.judul}
onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)} onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
label="Judul" label="Judul"
placeholder="Masukkan judul pengumuman" placeholder="Masukkan judul pengumuman"
@@ -88,32 +79,21 @@ function CreatePengumuman() {
<Select <Select
label="Kategori" label="Kategori"
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,
})) || []} }))}
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || null}
onChange={(val: string | null) => {
if (val) {
const selected = pengumumanState.category.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
pengumumanState.pengumuman.create.form.categoryPengumumanId = selected.id;
}
} else {
pengumumanState.pengumuman.create.form.categoryPengumumanId = '';
}
}}
searchable searchable
clearable
nothingFoundMessage="Tidak ditemukan" nothingFoundMessage="Tidak ditemukan"
required
/> />
{/* Deskripsi Singkat */} {/* Deskripsi Singkat */}
<TextInput <TextInput
value={pengumumanState.pengumuman.create.form.deskripsi} defaultValue={pengumumanState.pengumuman.create.form.deskripsi}
onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)} onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
label="Deskripsi Singkat" label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat" placeholder="Masukkan deskripsi singkat"
@@ -135,17 +115,6 @@ function CreatePengumuman() {
{/* Tombol Submit */} {/* Tombol Submit */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -156,7 +125,7 @@ function CreatePengumuman() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} Simpan
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

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